From 0efad9f804b93d3a846afe54acbe1f1728d8a5f0 Mon Sep 17 00:00:00 2001 From: Guillaume Sachet Date: Thu, 16 Jul 2026 09:37:16 +0200 Subject: [PATCH 1/2] feat(agno): introduce toolkits for chat history, user profile, and registry access - Added `ChatHistoryTools`, `UserProfileTools`, and `RegistryTools` to enhance agent capabilities. - Updated `pyproject.toml` to include the new optional dependency `agno`. - Introduced new module structure for toolkits under `digitalkin.community.agno.toolkits`. - Enhanced `ModuleServer` to include module type in initialization. - Updated registry models to support new module types and setups. - Improved error handling and logging in registry services. feat(storage): add Visibility field to StorageRecord and expand Scope type - Import and surface `Visibility` enum in `storage_strategy.py`, `default_storage.py`, and `grpc_storage.py` - Add `visibility` field to `StorageRecord` with default `Visibility.UNSPECIFIED` - Deserialize visibility from proto in `GrpcStorage._to_record()` - Deserialize visibility from JSON dict in `DefaultStorage._load_from_file()` - Expand `Scope` literal to include `\"user\"` and `\"organization\"` scopes Signed-off-by: Alexandre feat(storage): add ContextStorage enum and circuit-breaker handling - Introduce ContextStorage enum to type storage context values instead of raw strings, resolved via _resolve_context() into concrete storage keys - Add Visibility enum for record read-access scope, decoupled from context-based ownership - Add circuit-breaker fast-fail detection in GrpcStorage to log open-breaker rejections quietly during outages refactor(storage): accept pre-resolved context in DefaultStorage DefaultStorage methods previously accepted a ContextStorage value and resolved it internally via _resolve_context on every call. Since context resolution now happens upstream, storage methods take a plain resolved string context directly, dropping the redundant resolution step and the now-unused ContextStorage import. Signed-off-by: Alexandre chore(release): bump version to 1.0.2.dev5 fix(context): Fix context give to search_record for add user/org options Signed-off-by: Alexandre chore(release): bump version to 1.0.2.dev6 Signed-off-by: Alexandre chore(release): Add changelog for 1.0.2.dev6 and delete some dead code Signed-off-by: Alexandre feat(filesystem): add ContextFile scopes (mission/setup/user/organization) Introduce a typed `ContextFile` enum (mirroring the storage `ContextStorage` pattern) replacing the `Literal["mission", "setup"]` context argument on the filesystem service. Adds the two read-only cross-owner scopes USERS and ORGANIZATIONS: `get_file` / `get_files` / `FileFilter` now accept `ContextFile`, and `_context_enum` maps every kind to its wire enum (incl. CONTEXT_USERS / CONTEXT_ORGANIZATIONS / CONTEXT_UNSPECIFIED). Only the kind is sent on the wire; the concrete owner id is resolved server-side. Enum values stay singular so Pydantic coerces existing string call sites. fix(storage): map UNSPECIFIED context to the unspecified wire enum `ContextStorage.UNSPECIFIED` was silently resolved to the mission context (CONTEXT_MISSIONS) instead of being passed through. It now resolves to a `unspecified:` kind marker mapped to CONTEXT_UNSPECIFIED, matching the filesystem service and letting the server apply its default. Public callers are unaffected since the default context stays MISSIONS. chore(release): bump version to 1.0.2.dev7 Signed-off-by: Alexandre changelog(1.0.2.dev7): Add changelog file for filesystem change explanations Signed-off-by: Alexandre --- .bumpversion.toml | 2 +- .gitignore | 1 + .pre-commit-config.yaml | 2 +- docs/changelog/1.0.2.dev6.md | 199 ++ docs/changelog/1.0.2.dev7.md | 111 ++ pyproject.toml | 21 +- src/digitalkin/__version__.py | 2 +- src/digitalkin/community/agno/__init__.py | 5 + src/digitalkin/community/agno/hitl.py | 132 +- .../community/agno/module_toolkit.py | 6 +- .../community/agno/toolkits/__init__.py | 24 + .../community/agno/toolkits/base.py | 103 ++ .../community/agno/toolkits/chat_history.py | 298 +++ .../community/agno/toolkits/defaults.py | 69 + .../community/agno/toolkits/registry.py | 212 +++ .../community/agno/toolkits/setup.py | 225 +++ .../community/agno/toolkits/tool_loader.py | 139 ++ .../community/agno/toolkits/user_profile.py | 59 + .../core/job_manager/base_job_manager.py | 2 + .../core/job_manager/single_job_manager.py | 12 + .../core/task_manager/module_runner.py | 5 + src/digitalkin/grpc_servers/_base_server.py | 15 +- .../grpc_servers/gateway_servicer.py | 22 +- src/digitalkin/grpc_servers/module_server.py | 2 + .../grpc_servers/module_servicer.py | 18 +- src/digitalkin/mixins/storage_mixin.py | 5 +- .../models/module/module_context.py | 78 +- src/digitalkin/models/services/filesystem.py | 19 + src/digitalkin/models/services/registry.py | 61 +- src/digitalkin/models/services/storage.py | 23 + src/digitalkin/models/settings/registry.py | 29 + src/digitalkin/modules/_base_module.py | 27 +- src/digitalkin/modules/archetype_module.py | 2 + src/digitalkin/modules/tool_module.py | 4 + .../services/communication/exceptions.py | 4 + .../services/filesystem/default_filesystem.py | 3 +- .../filesystem/filesystem_strategy.py | 10 +- .../services/filesystem/grpc_filesystem.py | 62 +- .../services/registry/default_registry.py | 109 +- .../services/registry/grpc_registry.py | 226 ++- .../services/registry/registry_strategy.py | 143 +- .../services/setup/default_setup.py | 249 +-- src/digitalkin/services/setup/grpc_setup.py | 325 ++-- .../services/setup/setup_strategy.py | 116 +- .../services/storage/default_storage.py | 37 +- .../services/storage/grpc_storage.py | 107 +- .../services/storage/storage_strategy.py | 161 +- .../agno/test_dynamic_tool_loading.py | 255 +++ tests/community/agno/toolkits/__init__.py | 0 tests/community/agno/toolkits/conftest.py | 56 + .../agno/toolkits/test_base_toolkit.py | 57 + .../agno/toolkits/test_chat_history_tools.py | 178 ++ .../agno/toolkits/test_default_toolkits.py | 56 + .../agno/toolkits/test_registry_tools.py | 244 +++ .../agno/toolkits/test_setup_tools.py | 254 +++ .../agno/toolkits/test_tool_loader.py | 82 + .../agno/toolkits/test_user_profile_tools.py | 58 + tests/core/test_module_runner_m4.py | 91 + tests/gateway/test_dial_consumer.py | 77 + tests/gateway/test_tool_cache_servicer.py | 2 +- tests/modules/test_registry_documentation.py | 84 + tests/modules/test_tool_cache.py | 118 +- tests/modules/test_tool_function_fatal.py | 98 + tests/modules/test_tool_reference.py | 27 +- .../filesystem/mock_filesystem_servicer.py | 33 +- .../filesystem/test_grpc_filesystem.py | 110 +- .../registry/mock_registry_servicer.py | 163 +- .../registry/test_default_registry.py | 226 +++ tests/services/registry/test_grpc_registry.py | 332 +++- .../registry/test_registry_hardening.py | 102 ++ tests/services/setup/mock_setup_servicer.py | 291 +-- tests/services/setup/test_default_setup.py | 12 + tests/services/setup/test_grpc_setup.py | 1239 ++----------- .../services/storage/mock_storage_servicer.py | 13 +- tests/services/storage/test_grpc_storage.py | 150 +- tests/test_exceptions.py | 4 +- uv.lock | 1609 ++++++++--------- 77 files changed, 6573 insertions(+), 2934 deletions(-) create mode 100644 docs/changelog/1.0.2.dev6.md create mode 100644 docs/changelog/1.0.2.dev7.md create mode 100644 src/digitalkin/community/agno/toolkits/__init__.py create mode 100644 src/digitalkin/community/agno/toolkits/base.py create mode 100644 src/digitalkin/community/agno/toolkits/chat_history.py create mode 100644 src/digitalkin/community/agno/toolkits/defaults.py create mode 100644 src/digitalkin/community/agno/toolkits/registry.py create mode 100644 src/digitalkin/community/agno/toolkits/setup.py create mode 100644 src/digitalkin/community/agno/toolkits/tool_loader.py create mode 100644 src/digitalkin/community/agno/toolkits/user_profile.py create mode 100644 src/digitalkin/models/services/filesystem.py create mode 100644 src/digitalkin/models/settings/registry.py create mode 100644 tests/community/agno/test_dynamic_tool_loading.py create mode 100644 tests/community/agno/toolkits/__init__.py create mode 100644 tests/community/agno/toolkits/conftest.py create mode 100644 tests/community/agno/toolkits/test_base_toolkit.py create mode 100644 tests/community/agno/toolkits/test_chat_history_tools.py create mode 100644 tests/community/agno/toolkits/test_default_toolkits.py create mode 100644 tests/community/agno/toolkits/test_registry_tools.py create mode 100644 tests/community/agno/toolkits/test_setup_tools.py create mode 100644 tests/community/agno/toolkits/test_tool_loader.py create mode 100644 tests/community/agno/toolkits/test_user_profile_tools.py create mode 100644 tests/modules/test_registry_documentation.py create mode 100644 tests/modules/test_tool_function_fatal.py create mode 100644 tests/services/registry/test_default_registry.py create mode 100644 tests/services/registry/test_registry_hardening.py create mode 100644 tests/services/setup/test_default_setup.py diff --git a/.bumpversion.toml b/.bumpversion.toml index 76ffe902..fc34248e 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -3,7 +3,7 @@ # https://peps.python.org/pep-0440/ [tool.bumpversion] - current_version = "1.0.1.dev0" + current_version = "1.0.2.dev7" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/.gitignore b/.gitignore index 85b3c458..b3549b32 100644 --- a/.gitignore +++ b/.gitignore @@ -192,6 +192,7 @@ certs/ docker-compose.override.yml CLAUDE.md +/docker/init_pycharm_helpers.sh # Claude Code (share .claude/agents + .claude/skills; keep local settings out) .claude/settings.local.json \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8fe2b7d..074a4c0c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,7 +53,7 @@ repos: name: pytest # --build avoids stale images; TEST_MARKER env is honored by entrypoint-test.sh # (CLI args after the service name are ignored), so set the marker via env. - entry: docker compose run --rm -T --build -e "TEST_MARKER=not integration" tests + entry: docker compose -f docker-compose.yml run --rm -T --build -e "TEST_MARKER=not integration" tests language: system pass_filenames: false types: [python] diff --git a/docs/changelog/1.0.2.dev6.md b/docs/changelog/1.0.2.dev6.md new file mode 100644 index 00000000..1ed6fc7a --- /dev/null +++ b/docs/changelog/1.0.2.dev6.md @@ -0,0 +1,199 @@ +# 1.0.2.dev5 → 1.0.2.dev6 — Record Visibility & Context Scopes (incl. cross-owner USERS / ORGANIZATIONS) + +## Summary + +This changelog documents the storage work delivered across the **1.0.2.dev5 → 1.0.2.dev6** line. Two related +capabilities landed: + +1. **Record visibility** — every storage record now carries a read-access scope (`Visibility`) that is independent of + write ownership. You can tag a record `PUBLIC` / `PRIVATE` / `INTERNAL` on write and filter by it on list. +2. **Context scopes** — the old string `scope: Literal["mission", "setup"]` + argument on the storage service was replaced by a typed `ContextStorage` + enum, and the enum was extended with two **read-only cross-owner** scopes, + `USERS` and `ORGANIZATIONS`. `dev6` completes the client so those scopes are actually emitted on the wire — a kin can + now list records shared by other kins of the same user/organization. + +The concrete owner id for the cross-owner scopes is **resolved server-side** +from request metadata; the client only sends the context *kind*. + +> **Requirement:** these features need `agentic-mesh-protocol` with the +> `visibility` fields and the `CONTEXT_USERS` / `CONTEXT_ORGANIZATIONS` enum +> values (shipped in the proto ≥ `1.0.1.dev4`). It is pulled in transitively by +> this SDK version. + +## What changed + +### `ContextStorage` — the owner/scope of an operation (replaces `scope`) + +```python +from digitalkin.models.services.storage import ContextStorage + +class ContextStorage(Enum): + UNSPECIFIED = "unspecified" + MISSIONS = "missions" # this mission (default) + SETUP_VERSIONS = "setup_versions" # this setup version (shared across missions) + USERS = "users" # read-only: all kins of the same user + ORGANIZATIONS = "organizations" # read-only: all kins of the same organization +``` + +- `MISSIONS` (default) and `SETUP_VERSIONS` are **read/write** owner contexts. +- `USERS` and `ORGANIZATIONS` are **read-only, list-only** cross-owner scopes. The strategy holds no user/org id; it + sends only the kind and the storage service resolves the concrete id from the `x-user-id` / `x-organization-id` + request metadata. + +Every public storage method now takes `context: ContextStorage` instead of the old `scope: str`: + +| Method | Signature (relevant args) | +|---------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `store` | `store(collection, record_id, data, data_type=DataType.OUTPUT, context=ContextStorage.MISSIONS, visibility=Visibility.UNSPECIFIED)` | +| `read` | `read(collection, record_id, context=ContextStorage.MISSIONS)` | +| `update` | `update(collection, record_id, data, context=ContextStorage.MISSIONS, visibility=Visibility.UNSPECIFIED)` | +| `remove` | `remove(collection, record_id, context=ContextStorage.MISSIONS)` | +| `list` | `list(collection, context=ContextStorage.MISSIONS, visibilities=None)` | +| `remove_collection` | `remove_collection(collection, context=ContextStorage.MISSIONS)` | +| `upsert` | `upsert(collection, record_id, data, data_type=DataType.OUTPUT, context=ContextStorage.MISSIONS, visibility=Visibility.UNSPECIFIED)` | + +### `Visibility` — read-access scope of a record + +```python +from digitalkin.models.services.storage import Visibility + +class Visibility(Enum): + UNSPECIFIED = 0 # let the storage service apply its default + PUBLIC = 1 + PRIVATE = 2 + INTERNAL = 3 +``` + +- The integer values **mirror the storage proto** exactly. +- Ownership (who may *edit*) stays keyed on the record's `context`; `Visibility` + only governs who may *read* it. +- `StorageRecord` gained a `visibility: Visibility` field (default + `UNSPECIFIED`), populated from the wire on read. +- `visibility=UNSPECIFIED` is the proto default (`0`) and is wire-identical to not setting it, so the storage service + applies its own default. + +### Cross-owner wire mapping (completed in dev6) + +`GrpcStorage._context_enum` now maps the resolved context to the right wire enum, including the new cross-owner kinds: + +- `setup_versions:…` → `CONTEXT_SETUP_VERSIONS` +- `users:` → `CONTEXT_USERS` +- `organizations:` → `CONTEXT_ORGANIZATIONS` +- otherwise → `CONTEXT_MISSIONS` + +and `StorageStrategy._resolve_context` returns a kind-only marker (`users:` / +`organizations:`) for the cross-owner scopes, since the concrete id is resolved server-side. + +> **Local `DefaultStorage`** has no cross-owner data model, so listing under +> `USERS` / `ORGANIZATIONS` returns `[]` in local/dev mode. Cross-owner reads +> are a remote (`GrpcStorage`) capability. + +## How to use + +All examples assume you have a storage strategy (e.g. `context.storage` inside a trigger handler). + +### Write a record with a visibility + +```python +from digitalkin.models.services.storage import ContextStorage, Visibility + +# Readable by every kin of the same user, owned by this mission +await storage.store( + "reports", + "q3-summary", + {"title": "Q3", "body": "..."}, + visibility=Visibility.PUBLIC, +) + +# Persist under the setup version (survives across missions), keep it internal +await storage.upsert( + "shared_config", + "defaults", + {"lang": "fr"}, + context=ContextStorage.SETUP_VERSIONS, + visibility=Visibility.INTERNAL, +) +``` + +### Change a record's visibility later + +```python +# UNSPECIFIED leaves the current visibility unchanged +await storage.update("reports", "q3-summary", {"title": "Q3", "body": "..."}, + visibility=Visibility.PRIVATE) +``` + +### List and filter by visibility + +```python +# All readable records in this mission +records = await storage.list("reports") + +# Only PUBLIC + INTERNAL records +records = await storage.list( + "reports", + visibilities=[Visibility.PUBLIC, Visibility.INTERNAL], +) + +for r in records: + print(r.record_id, r.visibility.name, r.context) +``` + +### Cross-owner reads (discover data produced by other kins of the same user) + +```python +# Records other kins of the SAME USER created and shared, subject to visibility. +# The server resolves the concrete user id from the request metadata. +records = await storage.list( + "reports", + context=ContextStorage.USERS, + visibilities=[Visibility.PUBLIC], +) + +# Same, but across the whole organization +records = await storage.list("reports", context=ContextStorage.ORGANIZATIONS) +``` + +> Cross-owner scopes are **read-only**: use them with `list` only. `store` / +> `update` / `remove` always target the owning `MISSIONS` / `SETUP_VERSIONS` +> context. + +## Migration + +- **`scope=` → `context=`**: replace every `scope="mission"` / + `scope="setup"` string argument with `context=ContextStorage.MISSIONS` / + `context=ContextStorage.SETUP_VERSIONS`. The parameter was renamed and retyped from a `str` literal to the + `ContextStorage` enum, so passing the old string raises `TypeError`. +- **`data_type`**: pass the `DataType` enum (e.g. `DataType.OUTPUT`), not a string — `data_type="OUTPUT"` no longer + works. +- **New optional args**: `visibility` (on `store`/`update`/`upsert`) and + `visibilities` (on `list`) are optional; omit them to keep the previous behaviour (server default visibility, no + visibility filter). +- **No change to `read` / `remove` semantics** beyond the `scope` → `context` + rename. + +Minimal before/after: + +```python +# before (<= 1.0.0a0) +await storage.list("reports", scope="setup") +await storage.store("reports", "r1", data, data_type="OUTPUT") + +# after (>= 1.0.2.dev6) +from digitalkin.models.services.storage import ContextStorage, DataType + +await storage.list("reports", context=ContextStorage.SETUP_VERSIONS) +await storage.store("reports", "r1", data, data_type=DataType.OUTPUT) +``` + +## Verification + +Storage regression coverage lives in `tests/services/storage/`: + +- `test_grpc_storage.py` — round-trips `visibility` on `store`/`update`, the + `visibilities` filter on `list`, and + `test_list_cross_owner_context_and_visibilities` locks the wire mapping (`USERS → CONTEXT_USERS`, + `ORGANIZATIONS → CONTEXT_ORGANIZATIONS`). +- `test_storage_strategy_locks.py` — per-record lock keys use the resolved context string, so locks are created and + cleaned up under the right owner. diff --git a/docs/changelog/1.0.2.dev7.md b/docs/changelog/1.0.2.dev7.md new file mode 100644 index 00000000..42738727 --- /dev/null +++ b/docs/changelog/1.0.2.dev7.md @@ -0,0 +1,111 @@ +# 1.0.2.dev6 → 1.0.2.dev7 — Filesystem Context Scopes (mission/setup/user/organization) + +## Summary + +This release brings the **filesystem** service in line with the storage context model shipped in `dev6`. The string +`context: Literal["mission", "setup"]` +argument is replaced by a typed `ContextFile` enum, extended with the two **read-only cross-owner** scopes `USERS` and +`ORGANIZATIONS`. A file produced by one kin can now be read by another kin of the same user/organization, subject to +server-side access control. + +A small consistency fix also lands on the **storage** side: `ContextStorage.UNSPECIFIED` +now maps to the unspecified wire enum instead of being silently treated as MISSIONS, matching the filesystem behaviour. + +Only the context *kind* is sent on the wire — no id is transmitted by the client. The concrete owner (mission / setup / +user / organization) is resolved server-side from the request context. + +> **Requirement:** needs `agentic-mesh-protocol` with the filesystem +> `CONTEXT_USERS` / `CONTEXT_ORGANIZATIONS` enum values. It is pulled in +> transitively by this SDK version. + +## What changed + +### `ContextFile` — the owner/scope of a filesystem operation (replaces `scope`/`context` strings) + +```python +from digitalkin.models.services.filesystem import ContextFile + +class ContextFile(Enum): + UNSPECIFIED = "unspecified" + MISSIONS = "mission" # this mission (default) + SETUP = "setup" # this setup version + USERS = "user" # read-only: all kins of the same user + ORGANIZATIONS = "organization" # read-only: all kins of the same organization +``` + +- `MISSIONS` (default) and `SETUP` are the read/write owner contexts. +- `USERS` and `ORGANIZATIONS` are **read-only cross-owner** scopes (use them on reads: `get_file` / `get_files`). +- The enum values are singular strings, so Pydantic still coerces legacy string contexts on `FileFilter` (e.g. + `FileFilter(context="setup")`). + +Read methods now take `context: ContextFile`: + +| Method | Signature (relevant args) | +|-------------|---------------------------------------------------------------------------------------| +| `get_file` | `get_file(file_id, context=ContextFile.MISSIONS, *, include_content=False)` | +| `get_files` | `get_files(filters, ...)` where `filters.context: ContextFile = ContextFile.MISSIONS` | + +`_context_enum` maps every kind to its wire enum, including +`CONTEXT_USERS` / `CONTEXT_ORGANIZATIONS` / `CONTEXT_UNSPECIFIED`. + +### Writes stay owner-scoped + +`upload_files` / `update_file` / `delete_files` remain mission-scoped, exactly as before. Cross-owner scopes are +read-only — you cannot write into another kin's user/organization space. + +### Storage: UNSPECIFIED consistency fix + +`ContextStorage.UNSPECIFIED` now resolves to the `unspecified:` kind marker and maps to `CONTEXT_UNSPECIFIED` on the +wire (server applies its default), instead of silently becoming `CONTEXT_MISSIONS`. Public callers are unaffected — the +default context stays `MISSIONS`. + +## How to use + +```python +from digitalkin.models.services.filesystem import ContextFile +from digitalkin.services.filesystem.filesystem_strategy import FileFilter + +# Read a file owned by the current mission (default) +record = await filesystem.get_file(file_id, include_content=True) + +# Read a file from the setup-version scope +record = await filesystem.get_file(file_id, context=ContextFile.SETUP) + +# Cross-owner: list files shared by other kins of the same user. +# The server resolves the concrete user id; no id is sent by the client. +records, total = await filesystem.get_files( + FileFilter(context=ContextFile.USERS, prefix="reports/"), +) + +# Same across the whole organization +records, total = await filesystem.get_files(FileFilter(context=ContextFile.ORGANIZATIONS)) +``` + +## Migration + +- **`context="mission"` / `context="setup"` → `ContextFile`**: pass + `ContextFile.MISSIONS` / `ContextFile.SETUP` to `get_file`. For `FileFilter`, the legacy strings still validate + (Pydantic coerces them by value), but prefer the enum for clarity. +- **No change to write calls** (`upload_files` / `update_file` / `delete_files`). +- Import the enum from `digitalkin.models.services.filesystem`. + +Minimal before/after: + +```python +# before +await filesystem.get_file(file_id, context="setup") +await filesystem.get_files(FileFilter(context="mission", prefix="x/")) + +# after +from digitalkin.models.services.filesystem import ContextFile +await filesystem.get_file(file_id, context=ContextFile.SETUP) +await filesystem.get_files(FileFilter(context=ContextFile.MISSIONS, prefix="x/")) +``` + +## Verification + +- `tests/services/filesystem/test_grpc_filesystem.py::TestContextScopes` locks the wire mapping for all kinds — + `MISSIONS`, `SETUP`, `USERS`, `ORGANIZATIONS`, + `UNSPECIFIED` — on both `get_files` and `get_file`. +- `tests/services/storage/test_grpc_storage.py::TestListData` covers + `UNSPECIFIED → CONTEXT_UNSPECIFIED` alongside the cross-owner storage scopes. diff --git a/pyproject.toml b/pyproject.toml index 7236e937..3589687d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,17 +28,16 @@ dependencies = [ "ag-ui-protocol>=0.1.18", - "agentic-mesh-protocol==1.0.0b0", + "agentic-mesh-protocol==1.0.1.dev4", "anyio>=4.13.0", - "grpcio-health-checking==1.81.0", - "grpcio-reflection==1.81.0", - "grpcio-status==1.81.0", - "pydantic>=2.12.4", + "grpcio-health-checking==1.82.1", + "grpcio-reflection==1.82.1", + "grpcio-status==1.82.1", "pydantic-settings>=2.14.1", + "pydantic>=2.12.4", "redis[hiredis]>=7.4.0,<9", ] - - version = "1.0.1.dev0" + version = "1.0.2.dev7" [project.optional-dependencies] agno = [ "agno>=2.6" ] @@ -85,7 +84,7 @@ "twine>=6.2.0", "types-grpcio-health-checking>=1.0.0.20260518", "types-grpcio-reflection>=1.0.0.20260508", - "types-grpcio>=1.0.0.20260518", + "types-grpcio>=1.82.1.20260711", "types-protobuf>=7.34.1.20260518", "typos>=1.48.0", ] @@ -148,12 +147,12 @@ "buck-out", "build", "dist", - "scripts", "docs/*", "examples/*", "factory.py", "generate_certificates.py", "node_modules", + "scripts", "tests/*", "venv", ] @@ -270,10 +269,10 @@ skip-magic-trailing-comma = false [tool.mypy] - exclude = [ "examples", "tests", "scripts" ] + exclude = [ "examples", "scripts", "tests" ] ignore_missing_imports = true - warn_unused_ignores = true warn_redundant_casts = true + warn_unused_ignores = true [tool.pytest.ini_options] diff --git a/src/digitalkin/__version__.py b/src/digitalkin/__version__.py index 2f61ae63..bab33813 100644 --- a/src/digitalkin/__version__.py +++ b/src/digitalkin/__version__.py @@ -5,4 +5,4 @@ try: __version__ = version("digitalkin") except PackageNotFoundError: - __version__ = "1.0.1.dev0" + __version__ = "1.0.2.dev7" diff --git a/src/digitalkin/community/agno/__init__.py b/src/digitalkin/community/agno/__init__.py index 6bd5d8b9..4edb6c80 100644 --- a/src/digitalkin/community/agno/__init__.py +++ b/src/digitalkin/community/agno/__init__.py @@ -20,6 +20,11 @@ import it directly:: from digitalkin.community.agno.module_toolkit import ModuleToolkit + +Default agent toolkits (``ChatHistoryTools``, ``UserProfileTools``, +``RegistryTools``, ``DefaultToolkits``) live in +:mod:`digitalkin.community.agno.toolkits` — imported separately because they +require the optional ``agno`` dependency at import time. """ from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter diff --git a/src/digitalkin/community/agno/hitl.py b/src/digitalkin/community/agno/hitl.py index b5801f3d..ff0caa2e 100644 --- a/src/digitalkin/community/agno/hitl.py +++ b/src/digitalkin/community/agno/hitl.py @@ -23,6 +23,7 @@ from agno.agent import Agent from agno.run.agent import RunOutput + from digitalkin.community.agno.toolkits.tool_loader import ToolLoaderTools from digitalkin.models.events import BaseAgentRunEvent from digitalkin.models.module import ModuleContext from digitalkin.services.storage import StorageStrategy @@ -78,8 +79,10 @@ async def save(self, run_output: RunOutput, thread_id: str) -> PauseInfo: seen: set[str] = set() pending: list[str] = [] for tool in run_output.tools or []: - tid = getattr(tool, "tool_call_id", None) - if tid and tid not in seen and getattr(tool, "external_execution_required", False): + tid = tool.tool_call_id + # Skip tools already resolved in-process (e.g. a use_setup call handled by the + # runner): only genuinely unresolved external tools go to the front. + if tid and tid not in seen and tool.external_execution_required and tool.result is None: seen.add(tid) pending.append(tid) record = PausedRunRecord( @@ -303,6 +306,7 @@ def __init__( storage: StorageStrategy | None = None, store: PausedRunStore | None = None, dependency_key: str = "agui_tools", + tool_loader: ToolLoaderTools | None = None, ) -> None: """Initialize the runner. @@ -314,6 +318,12 @@ def __init__( store: Pre-built paused-run store; wins over ``storage``. dependency_key: Agno dependencies key carrying the AG-UI tool list (must match :func:`make_tools_factory`). + tool_loader: The :class:`ToolLoaderTools` bound to the agent's tool list + (``ToolLoaderTools.find(tools)``). When present, a ``use_setup`` pause is + resolved and the run auto-continues instead of surfacing to the front. + When omitted, the runner locates it in ``agent.tools`` itself — otherwise + a ``use_setup`` pause would surface to the front as a frontend tool no + client implements, wedging the thread. Raises: ValueError: If neither ``storage`` nor ``store`` is provided. @@ -323,9 +333,18 @@ def __init__( msg = "AgnoHitlRunner requires either `storage` or `store`." raise ValueError(msg) store = PausedRunStore(storage) + if tool_loader is None: + # Lazy import: ToolLoaderTools requires the optional agno dependency at + # import time, while this module must stay importable without it (same + # convention as the rest of community.agno). vars(): test fakes may not + # carry a tools attribute at all. + from digitalkin.community.agno.toolkits.tool_loader import ToolLoaderTools + + tool_loader = ToolLoaderTools.find(vars(agent).get("tools")) self._agent = agent self._store = store self._dependency_key = dependency_key + self._tool_loader = tool_loader async def run( self, @@ -360,7 +379,9 @@ async def run( yield_run_output=True, dependencies={self._dependency_key: agui_tools or []}, ) - return await self._drive(stream=stream, send=send, thread_id=thread_id, run_output_cls=RunOutput) + return await self._drive( + stream=stream, send=send, thread_id=thread_id, run_output_cls=RunOutput, agui_tools=agui_tools + ) async def continue_paused_run( self, @@ -437,7 +458,9 @@ async def continue_paused_run( yield_run_output=True, dependencies={self._dependency_key: agui_tools or []}, ) - pause_info = await self._drive(stream=stream, send=send, thread_id=thread_id, run_output_cls=RunOutput) + pause_info = await self._drive( + stream=stream, send=send, thread_id=thread_id, run_output_cls=RunOutput, agui_tools=agui_tools + ) if pause_info is None: await self._store.delete(thread_id) @@ -612,31 +635,108 @@ async def _drive( send: Callable[[BaseAgentRunEvent], Coroutine[Any, Any, None]], thread_id: str, run_output_cls: type[RunOutput], + agui_tools: list[AgUiTool] | None = None, ) -> PauseInfo | None: - """Drain an Agno stream, forward events, persist on pause. + """Drain an Agno stream, forward events, and persist or auto-continue on pause. + + A ``use_setup`` pause (dynamic tool load) is resolved in-process and the run + auto-continues with the enlarged tool list; a frontend-tool pause is persisted and + surfaced. The loop is bounded so a model that keeps calling ``use_setup`` cannot spin + forever. + + Args: + stream: The Agno event stream to drain. + send: Digitalkin-event callback for each forwarded event. + thread_id: AG-UI thread identifier (storage key on a frontend pause). + run_output_cls: The ``RunOutput`` class used to spot the terminal run object. + agui_tools: Frontend tools to re-pass to Agno on an auto-continue. Returns: - :class:`PauseInfo` on pause, ``None`` otherwise. + :class:`PauseInfo` on a frontend pause, ``None`` on completion. """ from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter - adapter = AgnoStreamAdapter() - final_run_output: RunOutput | None = None + for _ in range(20): + adapter = AgnoStreamAdapter() + final_run_output: RunOutput | None = None - async for raw_event in stream: - if isinstance(raw_event, run_output_cls): - final_run_output = raw_event - continue - for event in adapter.to_digitalkin_events(raw_event): + async for raw_event in stream: + if isinstance(raw_event, run_output_cls): + final_run_output = raw_event + continue + for event in adapter.to_digitalkin_events(raw_event): + await send(event) + + for event in adapter.flush(): await send(event) - for event in adapter.flush(): - await send(event) + if not (adapter.is_paused and final_run_output is not None and final_run_output.is_paused): + return None + + # Resolve any use_setup calls in-process; if the pause has nothing left for the + # front, auto-continue so discover -> load -> use reads as a single turn. + if await self._load_paused_tools(final_run_output) and not self._pending_external(final_run_output): + stream = self._agent.acontinue_run( + run_response=final_run_output, + stream=True, + stream_events=True, + yield_run_output=True, + dependencies={self._dependency_key: agui_tools or []}, + ) + continue - if adapter.is_paused and final_run_output is not None and getattr(final_run_output, "is_paused", False): pause_info = await self._store.save(run_output=final_run_output, thread_id=thread_id) # Attach AG-UI-shaped messages so the front can materialise the tool_call. pause_info.new_messages = HitlEvents.agno_messages_to_agui(final_run_output.messages or []) return pause_info + logger.warning("AgnoHitlRunner: auto-continue limit reached for thread_id=%s", thread_id) + from digitalkin.models.events import AgentRunEvent, RunErrorEvent + + await send( + RunErrorEvent( + event=AgentRunEvent.RUN_ERROR, + error_type="auto_continue_limit", + content=( + "The run was stopped after too many consecutive in-process tool " + "loads (use_setup). Send a new message to continue." + ), + error_details=None, + timestamp=None, + metadata=None, + ) + ) return None + + async def _load_paused_tools(self, run_output: RunOutput) -> bool: + """Resolve ``use_setup`` calls in a paused run, writing each tool result in place. + + Args: + run_output: The paused Agno run. + + Returns: + ``True`` if at least one ``use_setup`` call was handled, else ``False`` (no + loader wired, or the pause carries only frontend tools). + """ + if self._tool_loader is None: + return False + handled = False + loader_tool = self._tool_loader.tool_name + for tool in run_output.tools or []: + if tool.external_execution_required and tool.result is None and tool.tool_name == loader_tool: + setup_id = (tool.tool_args or {}).get("setup_id", "") + tool.result = await self._tool_loader.load(setup_id) + handled = True + return handled + + @staticmethod + def _pending_external(run_output: RunOutput) -> bool: + """Report whether any external tool in the paused run still needs a result. + + Args: + run_output: The paused Agno run (after :meth:`_load_paused_tools`). + + Returns: + ``True`` if a frontend tool call remains unresolved (must go to the front). + """ + return any(tool.external_execution_required and tool.result is None for tool in run_output.tools or []) diff --git a/src/digitalkin/community/agno/module_toolkit.py b/src/digitalkin/community/agno/module_toolkit.py index d1cbf651..31114384 100644 --- a/src/digitalkin/community/agno/module_toolkit.py +++ b/src/digitalkin/community/agno/module_toolkit.py @@ -16,10 +16,10 @@ from ag_ui.core.events import CustomEvent as AgUiCustomEvent from agno.media import Image -from agno.tools import Toolkit from agno.tools.function import Function, ToolResult from digitalkin.community.agno.models import ToolCallMetadata, ToolOutputMetadata +from digitalkin.community.agno.toolkits.base import DkToolkit from digitalkin.core.profiling.step_timer import StepTimer from digitalkin.logger import logger from digitalkin.models.module import ModuleContext @@ -38,7 +38,7 @@ AGUI_CUSTOM_PROTOCOL = "agui_custom" -class ModuleToolkit(Toolkit): +class ModuleToolkit(DkToolkit): """Agno Toolkit wrapper for SDK module tools. Wraps a ToolModuleInfo containing multiple ToolDefinitions into @@ -170,7 +170,7 @@ def __init__( or tool_module_info.module_name or tool_module_info.slug.replace(":", "_").replace(".", "_") ) - super().__init__(name=f"{toolkit_name}_toolkit", tools=agno_functions) + super().__init__(name=f"{toolkit_name}_toolkit", tools=agno_functions, context=self._context) @property def module_id(self) -> str: diff --git a/src/digitalkin/community/agno/toolkits/__init__.py b/src/digitalkin/community/agno/toolkits/__init__.py new file mode 100644 index 00000000..20010067 --- /dev/null +++ b/src/digitalkin/community/agno/toolkits/__init__.py @@ -0,0 +1,24 @@ +"""Default Agno toolkits for DigitalKin modules. + +Requires the optional ``agno`` dependency — importing this subpackage without +``agno`` installed raises ModuleNotFoundError. The parent +``digitalkin.community.agno`` package stays importable without agno. +""" + +from digitalkin.community.agno.toolkits.base import DkToolkit +from digitalkin.community.agno.toolkits.chat_history import ChatHistoryTools +from digitalkin.community.agno.toolkits.defaults import DefaultToolkits +from digitalkin.community.agno.toolkits.registry import RegistryTools +from digitalkin.community.agno.toolkits.setup import SetupTools +from digitalkin.community.agno.toolkits.tool_loader import ToolLoaderTools +from digitalkin.community.agno.toolkits.user_profile import UserProfileTools + +__all__ = [ + "ChatHistoryTools", + "DefaultToolkits", + "DkToolkit", + "RegistryTools", + "SetupTools", + "ToolLoaderTools", + "UserProfileTools", +] diff --git a/src/digitalkin/community/agno/toolkits/base.py b/src/digitalkin/community/agno/toolkits/base.py new file mode 100644 index 00000000..394a5d3a --- /dev/null +++ b/src/digitalkin/community/agno/toolkits/base.py @@ -0,0 +1,103 @@ +"""Shared base for DigitalKin agno toolkits. + +Codifies the format/return conventions established by +:class:`~digitalkin.community.agno.module_toolkit.ModuleToolkit`: a canonical +``{"output"|"error", "metadata"}`` JSON envelope (:meth:`_ok`/:meth:`_fail`) and +best-effort AG-UI custom-event notifications on the agent's own stream +(:meth:`_notify`). Every toolkit returns consistently and never raises into the +agent loop. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from ag_ui.core.events import CustomEvent as AgUiCustomEvent +from agno.tools import Toolkit + +from digitalkin.logger import logger +from digitalkin.models.module.ag_ui import AgUiCustomEventOutput, AgUiOutput + +if TYPE_CHECKING: + from digitalkin.models.module import ModuleContext + + +class DkToolkit(Toolkit): + """Base class for DigitalKin agno toolkits. + + Subclasses register bound async tool methods and return via :meth:`_ok`/ + :meth:`_fail` so the agent always receives the same envelope and the tool + never raises. Passing a :class:`ModuleContext` enables :meth:`_notify`, + which pushes AG-UI custom events onto the caller's gRPC stream. + """ + + def __init__( + self, + name: str, + tools: list[Any], + context: ModuleContext | None = None, + external_execution_required_tools: list[str] | None = None, + ) -> None: + """Initialize the toolkit. + + Args: + name: Toolkit name registered with Agno. + tools: Bound tool callables to expose to the agent. + context: Module context; when present, :meth:`_notify` can emit AG-UI events. + external_execution_required_tools: Tool names Agno must pause on (executed + outside the agent loop) instead of running their entrypoint. + """ + self._ctx = context + super().__init__( + name=name, + tools=tools, + external_execution_required_tools=external_execution_required_tools or [], + ) + + @staticmethod + def _ok(output: Any, **metadata: Any) -> str: + """Build the canonical success envelope. + + Args: + output: The tool result payload (JSON-serializable). + metadata: Extra metadata fields (e.g. ``tool``). + + Returns: + JSON string ``{"output": ..., "metadata": {"success": true, ...}}``. + """ + return json.dumps({"output": output, "metadata": {"success": True, **metadata}}, ensure_ascii=False) + + @staticmethod + def _fail(error: str, **metadata: Any) -> str: + """Build the canonical error envelope. + + Args: + error: Human/LLM-readable error message. + metadata: Extra metadata fields (e.g. ``tool``). + + Returns: + JSON string ``{"error": ..., "metadata": {"success": false, ...}}``. + """ + return json.dumps({"error": error, "metadata": {"success": False, **metadata}}, ensure_ascii=False) + + async def _notify(self, name: str, value: Any) -> None: + """Emit an AG-UI custom event on the agent's output stream (best-effort). + + No-op when there is no context or ``send_message`` is not installed (e.g. outside a + running job). Never raises — a notification failure must not fail the tool call. + + Args: + name: Custom event name. + value: Custom event payload (JSON-serializable). + """ + if self._ctx is None: + return + # callbacks is a dict-driven SimpleNamespace; send_message is attached during prepare(). + send_message = vars(self._ctx.callbacks).get("send_message") + if send_message is None: + return + try: + await send_message(AgUiOutput(root=AgUiCustomEventOutput(event=AgUiCustomEvent(name=name, value=value)))) + except Exception: + logger.exception("Failed to emit custom event '%s' to the agent stream", name) diff --git a/src/digitalkin/community/agno/toolkits/chat_history.py b/src/digitalkin/community/agno/toolkits/chat_history.py new file mode 100644 index 00000000..9c632633 --- /dev/null +++ b/src/digitalkin/community/agno/toolkits/chat_history.py @@ -0,0 +1,298 @@ +"""Toolkit for progressive chat-history access (outline first, then read by id). + +Replaces Agno's built-in ``get_chat_history`` (which dumps every message in full) +with a two-step, token-cheap surface: + +1. ``outline_chat_history`` — a metadata-only index (role, who, size, preview) so the + agent can see *what* exists before loading anything. +2. ``read_chat_messages`` — fetch full content only for the message ids that matter. + +The toolkit is leader-only: it is attached to the head agent / team leader and its +underlying ``aget_session_messages`` call skips team-member sub-conversations. + +Note: the tools intentionally take NO ``run_context`` parameter. The session id is +captured at construction and the runtime Agent/Team is late-bound as ``host`` — so +every tool parameter is a plain builtin type, which keeps the LLM-facing JSON schema +correct under ``from __future__ import annotations``. +""" + +from __future__ import annotations + +from itertools import starmap +from typing import TYPE_CHECKING, Any, ClassVar + +from digitalkin.community.agno.toolkits.base import DkToolkit +from digitalkin.logger import logger + +if TYPE_CHECKING: + from collections.abc import Callable + + from agno.media import Audio, File, Image, Video + from agno.models.message import Message + + from digitalkin.models.module import ModuleContext + + +class ChatHistoryTools(DkToolkit): + """Two-tool chat-history surface bound to a constructed Agent or Team. + + ``host`` is late-bound after the agent/team is created (the same pattern Agno uses + for its own history tools, which close over the agent + session). Until bound, the + tools report that history is unavailable rather than raising. + """ + + # Agno message roles -> the labels surfaced to the LLM. + _ROLE_TO_LABEL: ClassVar[dict[str, str]] = {"user": "human", "assistant": "ai", "tool": "tool", "system": "system"} + + # Requested label -> Agno ``skip_roles`` (which roles to exclude). ``system`` is special-cased. + _LABEL_TO_SKIP: ClassVar[dict[str, list[str]]] = { + "human": ["system", "assistant", "tool"], + "ai": ["system", "user", "tool"], + "tool": ["system", "user", "assistant"], + "system": [], + } + + def __init__(self, session_id: str | None = None, context: ModuleContext | None = None) -> None: + """Register the outline + read tools. + + Args: + session_id: The session whose history to read. Captured here (it is known at + agent-construction time) so the tools need no ``run_context`` parameter. + context: Module context; enables AG-UI notifications via the base toolkit. + """ + super().__init__( + name="chat_history_tools", + tools=[self.outline_chat_history, self.read_chat_messages], + context=context, + ) + self._session_id = session_id + # Late-bound to the runtime Agent (single mode) or Team (team mode). + self.host: Any = None + + @staticmethod + def bind_host(tools: list[Any] | Callable[..., list[Any]] | None, host: Any) -> None: + """Late-bind the runtime Agent/Team into the ChatHistoryTools instance, if present. + + The toolkit needs a handle to call ``aget_session_messages``, which only exists once + the agent/team is constructed. In team mode call this again with the ``Team`` so + history reads target the team session rather than the bare head agent. + + Args: + tools: The head tools — either the raw list or an + :meth:`AguiTools.make_tools_factory` callable (calling it without a + RunContext returns the base list). Binds the first ChatHistoryTools + found; no-op if absent. + host: The constructed Agent or Team to bind as the history source. + """ + if callable(tools) and not isinstance(tools, list): + tools = tools(None) + if not isinstance(tools, list): + return + for tool in tools: + if isinstance(tool, ChatHistoryTools): + tool.host = host + return + + async def outline_chat_history( + self, + role: str | None = None, + first: int | None = None, + last: int | None = None, + offset: int = 0, + ) -> str: + """List the conversation as a cheap metadata index — call this FIRST. + + Returns one lightweight row per message (role, who, timestamp, size and a short + preview) WITHOUT the full content, so it is safe to scan a long thread. Once you + know which messages you need, call ``read_chat_messages`` with their ids to get + full content. Prefer this over loading everything. + + Args: + role: Filter by message type: "human", "ai", "tool", or "system". + Omit to get all messages except the system prompt. + first: Return only the first N messages (oldest). Use this to reach the start + of the conversation, e.g. the user's first request. + last: Return only the last N messages (most recent). Mutually exclusive with first. + offset: Skip this many messages from the relevant end (for pagination). + + Returns: + JSON string: {"total", "returned", "offset", "messages": [{"ord", "id", "role", + "ts", "chars", "preview", ...}]}. "total" is the full count after filtering, so + an empty "messages" with "total": 0 means the thread is genuinely empty. + """ + if role is not None and role not in self._LABEL_TO_SKIP: + msg = f"invalid role '{role}'; use one of: human, ai, tool, system" + return self._fail(msg, tool="outline_chat_history") + + skip_roles = self._LABEL_TO_SKIP[role] if role is not None else ["system"] + messages = await self._fetch(skip_roles) + if messages is None: + return self._fail("chat history is not available", tool="outline_chat_history") + if role == "system": + messages = [m for m in messages if m.role == "system"] + + rows = list(starmap(self._index_row, enumerate(messages))) + total = len(rows) + + if first is not None: + sliced = rows[offset : offset + max(first, 0)] + elif last is not None: + end = max(total - offset, 0) + start = max(end - max(last, 0), 0) + sliced = rows[start:end] + else: + sliced = rows[offset:] + + return self._ok( + {"total": total, "returned": len(sliced), "offset": offset, "messages": sliced}, + tool="outline_chat_history", + ) + + async def read_chat_messages( + self, + ids: list[str], + max_content_chars: int = 4000, + ) -> str: + """Fetch the full content of specific messages by id (from ``outline_chat_history``). + + Use the "id" values returned by ``outline_chat_history`` — they are stable even as + the conversation grows (unlike the "ord" position). Long bodies are truncated to + ``max_content_chars``; attached media is returned as a reference, never inlined. + + Args: + ids: The message ids to read, taken from an earlier ``outline_chat_history`` call. + max_content_chars: Truncate each message body to this many characters (default 4000). + + Returns: + JSON string: {"messages": [{"id", "role", "ts", "content", ...}], "missing": [...]}. + Any requested id that no longer exists is listed under "missing". + """ + if not ids: + return self._ok({"messages": [], "missing": []}, tool="read_chat_messages") + + messages = await self._fetch(skip_roles=[]) + if messages is None: + return self._fail("chat history is not available", tool="read_chat_messages") + + by_id = {message.id: message for message in messages} + out: list[dict[str, Any]] = [] + missing: list[str] = [] + for message_id in ids: + message = by_id.get(message_id) + if message is None: + missing.append(message_id) + else: + out.append(self._full_row(message, max_content_chars)) + + return self._ok({"messages": out, "missing": missing}, tool="read_chat_messages") + + async def _fetch(self, skip_roles: list[str]) -> list[Message] | None: + """Load session messages via the bound agent/team, or None if unavailable. + + Args: + skip_roles: Roles to exclude (passed to Agno's ``aget_session_messages``). + + Returns: + The deduplicated session messages, or None if no host is bound or the call fails. + """ + if self.host is None: + logger.warning("ChatHistoryTools called before host was bound") + return None + try: + return await self.host.aget_session_messages( + session_id=self._session_id, + skip_roles=skip_roles, + skip_history_messages=True, + ) + except Exception as error: + logger.warning("ChatHistoryTools: failed to load session messages: %s", error) + return None + + def _index_row(self, ordinal: int, message: Message) -> dict[str, Any]: + """Build a metadata-only index row for one message. + + Args: + ordinal: Position of the message in the filtered list (display-only). + message: The Agno message. + + Returns: + A compact dict with role, id, timestamp, size and a short preview. + """ + content = message.get_content_string() or "" + row: dict[str, Any] = { + "ord": ordinal, + "id": message.id, + "role": self._ROLE_TO_LABEL.get(message.role, message.role), + "ts": message.created_at, + "chars": len(content), + "preview": content[:120], + } + if message.images or message.files or message.videos or message.audio: + row["has_media"] = True + if message.from_history: + row["from_history"] = True + if message.role == "tool": + row["name"] = message.tool_name + if message.tool_call_error: + row["error"] = True + return row + + def _full_row(self, message: Message, max_content_chars: int) -> dict[str, Any]: + """Build a full-content row for one message, truncating the body if needed. + + Args: + message: The Agno message. + max_content_chars: Maximum body length before truncation. + + Returns: + A dict with the (possibly truncated) content plus media references. + """ + content = message.get_content_string() or "" + truncated = len(content) > max_content_chars + if truncated: + content = content[:max_content_chars] + " […truncated]" + + row: dict[str, Any] = { + "id": message.id, + "role": self._ROLE_TO_LABEL.get(message.role, message.role), + "ts": message.created_at, + "content": content, + } + if truncated: + row["truncated"] = True + if message.role == "tool": + row["name"] = message.tool_name + if message.tool_call_error: + row["error"] = True + media = self._media_refs(message) + if media: + row["media"] = media + return row + + @staticmethod + def _media_refs(message: Message) -> list[dict[str, Any]]: + """Build reference descriptors for attached media — never the raw bytes. + + Args: + message: The Agno message. + + Returns: + A list of {"kind", "id", "mime_type", "format"} descriptors. + """ + groups: tuple[tuple[str, Any], ...] = ( + ("image", message.images), + ("audio", message.audio), + ("video", message.videos), + ("file", message.files), + ) + refs: list[dict[str, Any]] = [] + for kind, items in groups: + for item in items or []: + media_item: Image | Audio | Video | File = item + refs.append({ + "kind": kind, + "id": media_item.id, + "mime_type": media_item.mime_type, + "format": media_item.format, + }) + return refs diff --git a/src/digitalkin/community/agno/toolkits/defaults.py b/src/digitalkin/community/agno/toolkits/defaults.py new file mode 100644 index 00000000..0777983e --- /dev/null +++ b/src/digitalkin/community/agno/toolkits/defaults.py @@ -0,0 +1,69 @@ +"""One-call assembler for the default DigitalKin toolkits.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from digitalkin.community.agno.toolkits.chat_history import ChatHistoryTools +from digitalkin.community.agno.toolkits.registry import RegistryTools +from digitalkin.community.agno.toolkits.setup import SetupTools +from digitalkin.community.agno.toolkits.tool_loader import ToolLoaderTools +from digitalkin.community.agno.toolkits.user_profile import UserProfileTools + +if TYPE_CHECKING: + from collections.abc import Callable + + from agno.tools import Toolkit + + from digitalkin.models.module.module_context import ModuleContext + + +class DefaultToolkits: + """Assemble the default DigitalKin toolkits (chat history, user profile, registry). + + Two-phase usage — ChatHistoryTools needs the constructed Agent/Team:: + + tools = DefaultToolkits.build(context, session_id=sid) + agent = Agent(tools=AguiTools.make_tools_factory(tools), cache_callables=False, ...) + DefaultToolkits.bind_host(tools, agent) + + In team mode call :meth:`bind_host` again with the ``Team`` so history reads + target the team session rather than the bare head agent. + """ + + @staticmethod + def build(context: ModuleContext, session_id: str | None = None) -> list[Toolkit]: + """Build the default toolkits from a module context. + + Includes SetupTools only when ``context.setup`` is wired (the servicer's shared + setup service). ToolLoaderTools is always added and bound to the returned list so + dynamically-loaded tools land in the exact list the agent's factory splats. + + Args: + context: The module context carrying the services and (optional) setup service. + session_id: The Agno session whose chat history should be readable. + + Returns: + [ChatHistoryTools, UserProfileTools, RegistryTools, (SetupTools?), ToolLoaderTools]. + """ + tools: list[Toolkit] = [ + ChatHistoryTools(session_id=session_id, context=context), + UserProfileTools(context.user_profile, context=context), + RegistryTools(context.registry, context=context), + ] + if context.setup is not None: + tools.append(SetupTools(context.setup, context=context)) + loader = ToolLoaderTools(context=context) + tools.append(loader) + loader.bind_tools(tools) + return tools + + @staticmethod + def bind_host(tools: list[Any] | Callable[..., list[Any]] | None, host: Any) -> None: + """Late-bind the constructed Agent/Team into ChatHistoryTools (delegates). + + Args: + tools: The tools list (or a make_tools_factory callable) containing the toolkits. + host: The constructed Agent or Team to bind as the history source. + """ + ChatHistoryTools.bind_host(tools, host) diff --git a/src/digitalkin/community/agno/toolkits/registry.py b/src/digitalkin/community/agno/toolkits/registry.py new file mode 100644 index 00000000..baea9dea --- /dev/null +++ b/src/digitalkin/community/agno/toolkits/registry.py @@ -0,0 +1,212 @@ +"""Toolkit exposing the DigitalKin registry to the agent (setup + module search).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from digitalkin.community.agno.toolkits.base import DkToolkit +from digitalkin.grpc_servers.exceptions import PermissionDeniedError +from digitalkin.logger import logger +from digitalkin.models.services.registry import RegistryModuleType, RegistrySetupStatus +from digitalkin.services.registry.exceptions import RegistryServiceError + +if TYPE_CHECKING: + from digitalkin.models.module import ModuleContext + from digitalkin.services.registry.registry_strategy import RegistryStrategy + + +class RegistryTools(DkToolkit): + """Search the DigitalKin registry: invocable setups and the module catalog. + + ``search_setups`` returns configured, ready-to-use instances (carrying a + ``setup_id``); ``search_modules`` browses raw module types. Results are trimmed + for the LLM: no configuration, no network addresses, truncated documentation. + """ + + _DOC_PREVIEW_CHARS: ClassVar[int] = 300 + _MAX_RESULTS: ClassVar[int] = 25 + _UNAVAILABLE: ClassVar[str] = "registry search is temporarily unavailable, retry shortly" + + def __init__(self, registry: RegistryStrategy, context: ModuleContext | None = None) -> None: + """Initialize toolkit with the setup and module search tools. + + Args: + registry: The module's registry service strategy. + context: Module context; enables AG-UI notifications via the base toolkit. + """ + self._registry = registry + super().__init__( + name="registry_tools", + tools=[self.search_setups, self.search_modules, self.get_service_setup], + context=context, + ) + + @staticmethod + def _invalid_kind(kind: str | None) -> str | None: + """Return an error message for an invalid ``kind``, or None if valid. + + Args: + kind: The requested kind filter. + + Returns: + Error message when ``kind`` is not 'tool', 'kin' or 'service', else None. + """ + if kind is not None and kind not in {"tool", "kin", "service"}: + return f"invalid kind '{kind}'; use 'tool', 'kin' or 'service'" + return None + + def _clamp(self, limit: int) -> int: + """Clamp a requested result count to ``[1, _MAX_RESULTS]``. + + Args: + limit: Requested max results. + + Returns: + The clamped limit. + """ + return min(max(limit, 1), self._MAX_RESULTS) + + async def search_setups( + self, query: str | None = None, kind: Literal["tool", "kin", "service"] | None = None, limit: int = 10 + ) -> str: + """Search ready-to-use setups (configured agent/tool/service instances you can actually invoke). + + A setup is an installed, configured instance of a module — the thing you can + call. Use this to discover which tools, agents (kins) or services are available. + + Args: + query: Free text matched against setup name and documentation. Omit to list all. + kind: Optional filter: "tool" (invocable tools), "kin" (agents) or "service". + limit: Max results (default 10, max 25). + + Returns: + The canonical envelope; ``output`` = {"total_returned", "truncated", "setups": [...]}. + """ + bad = self._invalid_kind(kind) + if bad: + return self._fail(bad, tool="search_setups") + + cap = self._clamp(limit) + try: + setups = await self._registry.search_setups( + query=query, + module_types=[ + { + "tool": RegistryModuleType.TOOL_MODULE, + "kin": RegistryModuleType.ARCHETYPE, + "service": RegistryModuleType.SERVICE, + }[kind] + ] + if kind + else None, + statuses=[RegistrySetupStatus.READY, RegistrySetupStatus.CONFIGURATION_SUCCEEDED], + limit=cap, + ) + except PermissionDeniedError: + return self._fail("permission denied: search_setups", tool="search_setups") + except ValueError as error: + # Enum encoding drift (fail-closed): permanent, retrying will not help. + logger.error("RegistryTools: setup search filter rejected: %s", error) + return self._fail("search filter not supported by this registry version", tool="search_setups") + except RegistryServiceError as error: + logger.warning("RegistryTools: setup search failed: %s", error) + return self._fail(self._UNAVAILABLE, tool="search_setups") + + rows = [ + { + "setup_id": setup.setup_id, + "name": setup.name, + "kind": setup.module_type.value if setup.module_type else None, + "module_name": setup.module_name, + "version": setup.setup_version, + "description": (setup.documentation or "")[: self._DOC_PREVIEW_CHARS], + } + for setup in setups + ] + return self._ok( + {"total_returned": len(rows), "truncated": len(rows) == cap, "setups": rows}, + tool="search_setups", + ) + + async def get_service_setup(self, setup_id: str) -> str: + """Fetch a service's configuration content (a JSON document) by setup id. + + Use after discovering a service via ``search_setups`` and the user accepted it: + pass the proposed setup's ``setup_id`` to read the service content. Always + returns the latest version. + + Args: + setup_id: The service setup id (from a ``search_setups`` result). + + Returns: + The canonical envelope; ``output`` = the service configuration JSON object. + """ + try: + content = await self._registry.get_service_setup(setup_id) + except PermissionDeniedError: + return self._fail("permission denied: get_service_setup", tool="get_service_setup") + except RegistryServiceError as error: + logger.warning("RegistryTools: service setup fetch failed: %s", error) + return self._fail(self._UNAVAILABLE, tool="get_service_setup") + + if content is None: + return self._fail(f"service setup '{setup_id}' not found or has no content", tool="get_service_setup") + return self._ok(content, tool="get_service_setup") + + async def search_modules( + self, query: str | None = None, kind: Literal["tool", "kin", "service"] | None = None, limit: int = 10 + ) -> str: + """Search the module catalog (module TYPES, not configured instances). + + A module is a blueprint — it needs a setup before it can be invoked. Use + ``search_setups`` to find something you can actually call; use this to browse + what exists in the mesh. + + Args: + query: Free text matched against module names. Omit to list all. + kind: Optional filter: "tool", "kin" (agents/archetypes) or "service". + limit: Max results (default 10, max 25). + + Returns: + The canonical envelope; ``output`` = {"total_returned", "truncated", "modules": [...]}. + """ + bad = self._invalid_kind(kind) + if bad: + return self._fail(bad, tool="search_modules") + + cap = self._clamp(limit) + if kind == "tool": + pending = self._registry.search_tools(name=query, limit=cap) + elif kind == "kin": + pending = self._registry.search_kins(name=query, limit=cap) + elif kind == "service": + pending = self._registry.search_services(name=query, limit=cap) + else: + pending = self._registry.search(name=query, limit=cap) + try: + modules = await pending + except PermissionDeniedError: + return self._fail("permission denied: search_modules", tool="search_modules") + except ValueError as error: + # Enum encoding drift (fail-closed): permanent, retrying will not help. + logger.error("RegistryTools: module search filter rejected: %s", error) + return self._fail("search filter not supported by this registry version", tool="search_modules") + except RegistryServiceError as error: + logger.warning("RegistryTools: module search failed: %s", error) + return self._fail(self._UNAVAILABLE, tool="search_modules") + + rows = [ + { + "module_id": module.module_id, + "name": module.module_name, + "kind": module.module_type.value, + "version": module.version, + "status": module.status.value if module.status else None, + "description": (module.documentation or "")[: self._DOC_PREVIEW_CHARS], + } + for module in modules + ] + return self._ok( + {"total_returned": len(rows), "truncated": len(rows) == cap, "modules": rows}, + tool="search_modules", + ) diff --git a/src/digitalkin/community/agno/toolkits/setup.py b/src/digitalkin/community/agno/toolkits/setup.py new file mode 100644 index 00000000..085275be --- /dev/null +++ b/src/digitalkin/community/agno/toolkits/setup.py @@ -0,0 +1,225 @@ +"""Toolkit exposing the DigitalKin setup service to the agent (setup CRUD). + +Wraps the same ``SetupStrategy`` instance the module servicer already uses for the +base StartStream/Stream flow (shared gRPC channel, borrowed on ``context.setup``), +so the agent can create/read/update/delete setups and change their visibility. +Owner/organisation/module of a created setup are resolved server-side from the +request context; version lifecycle is platform-owned (content flows through the +setup's ``current_setup_version``). Every tool returns the canonical envelope and +never raises into the agent loop; permission denials are surfaced distinctly. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from google.protobuf.message import Message as ProtoMessage +from pydantic import BaseModel + +from digitalkin.community.agno.toolkits.base import DkToolkit +from digitalkin.grpc_servers.exceptions import PermissionDeniedError, ServerError +from digitalkin.logger import logger +from digitalkin.services.setup.exceptions import SetupServiceError +from digitalkin.utils.proto_utils import ProtoUtils + +if TYPE_CHECKING: + from collections.abc import Awaitable + + from digitalkin.models.module import ModuleContext + from digitalkin.services.setup.setup_strategy import SetupStrategy + + +class SetupTools(DkToolkit): + """CRUD + visibility access to the DigitalKin setup service over the module's shared channel. + + A setup is a configured instance of a module; its content lives in the embedded + ``current_setup_version``. The tools build the flat dicts the ``SetupStrategy`` + expects and return a JSON envelope; results are normalised to plain JSON + regardless of whether the backend returns a Pydantic model, a proto, or a scalar. + """ + + def __init__(self, setup: SetupStrategy, context: ModuleContext | None = None) -> None: + """Initialize the toolkit with the module's setup service. + + Args: + setup: The setup service strategy (shared with the servicer's base flow). + context: Module context; enables AG-UI notifications via the base toolkit. + """ + self._setup = setup + super().__init__( + name="setup_tools", + tools=[ + self.get_setup, + self.create_setup, + self.create_service, + self.update_setup, + self.delete_setup, + self.change_visibility, + ], + context=context, + ) + + async def _guard(self, op: str, coro: Awaitable[Any]) -> tuple[bool, Any]: + """Await a setup-service call, converting failures into a fail envelope. + + Args: + op: Tool name, used in the error message and metadata. + coro: The setup-service coroutine to await. + + Returns: + ``(True, result)`` on success; ``(False, fail_envelope)`` on any + error — never raises into the agent loop. + """ + try: + return True, await coro + except PermissionDeniedError: + return False, self._fail(f"permission denied: {op}", tool=op) + except (SetupServiceError, ServerError, ValueError) as error: + logger.warning("SetupTools: %s failed: %s", op, error) + return False, self._fail(str(error), tool=op) + except Exception as error: + # Backend contract surprises (KeyError, TypeError, ...) must not + # raise into the agent loop either. + logger.exception("SetupTools: %s failed unexpectedly", op) + return False, self._fail(f"{op} failed: {type(error).__name__}: {error}", tool=op) + + async def _invalidate(self) -> None: + """Invalidate the servicer's setup cache after a successful write (best-effort). + + No-op when the callback is not installed (e.g. outside the M4 flow). + """ + if self._ctx is None: + return + invalidate = vars(self._ctx.callbacks).get("invalidate_setup") + if invalidate is None: + return + try: + invalidate() + except Exception: + logger.exception("SetupTools: setup-cache invalidation failed") + + @staticmethod + def _jsonable(value: Any) -> Any: + """Normalise a backend return value to a JSON-serializable form. + + Args: + value: A Pydantic model, proto message, or plain scalar/collection. + + Returns: + A dict for models/protos, otherwise the value unchanged. + """ + if isinstance(value, BaseModel): + return value.model_dump(mode="json") + if isinstance(value, ProtoMessage): + return ProtoUtils.proto_to_dict(value) + return value + + async def get_setup(self, setup_id: str, version: str = "") -> str: + """Fetch a setup by id (optionally a specific version). + + Args: + setup_id: The setup id to read. + version: Optional version to pin; omit for the current version. + + Returns: + The canonical envelope; ``output`` = the setup with its current version, + status and visibility. + """ + ok, result = await self._guard("get_setup", self._setup.get_setup({"setup_id": setup_id, "version": version})) + return result if not ok else self._ok(self._jsonable(result), tool="get_setup") + + async def create_setup(self, name: str, content: dict[str, Any]) -> str: + """Create a new setup with an initial version. + + The owner, organisation and target module are derived server-side from + this request's context — only a name and the configuration content are + needed. New setups start private; use ``change_visibility`` to share them. + + Args: + name: Human-readable setup name. + content: The initial version's configuration payload. + + Returns: + The canonical envelope; ``output`` = the created setup (id, status, + visibility, current version). + """ + ok, result = await self._guard("create_setup", self._setup.create_setup({"name": name, "content": content})) + if not ok: + return result + await self._invalidate() + return self._ok(self._jsonable(result), tool="create_setup") + + async def create_service(self, name: str, content: dict[str, Any]) -> str: + """Create a new service (a shareable configuration document other kins can discover). + + Only a name and the configuration JSON are needed — the platform handles the + rest. Once created it is discoverable via ``search_setups`` and readable via + ``get_service_setup``. + + Args: + name: Human-readable service name. + content: The service configuration JSON. + + Returns: + The canonical envelope; ``output`` = the created service setup. + """ + ok, result = await self._guard("create_service", self._setup.create_service_setup(name, content)) + if not ok: + return result + await self._invalidate() + return self._ok(self._jsonable(result), tool="create_service") + + async def update_setup(self, setup_id: str, name: str, content: dict[str, Any]) -> str: + """Update an existing setup's name and current version content. + + Args: + setup_id: The setup to update. + name: New setup name. + content: The current version's new configuration payload. + + Returns: + The canonical envelope; ``output`` = the updated setup. + """ + ok, result = await self._guard( + "update_setup", + self._setup.update_setup({"setup_id": setup_id, "name": name, "content": content}), + ) + if not ok: + return result + await self._invalidate() + return self._ok(self._jsonable(result), tool="update_setup") + + async def delete_setup(self, setup_id: str) -> str: + """Delete a setup by id. + + Args: + setup_id: The setup to delete. + + Returns: + The canonical envelope; ``output`` = the deletion result. + """ + ok, result = await self._guard("delete_setup", self._setup.delete_setup({"setup_id": setup_id})) + if not ok: + return result + await self._invalidate() + return self._ok(self._jsonable(result), tool="delete_setup") + + async def change_visibility(self, setup_id: str, visibility: Literal["public", "private", "internal"]) -> str: + """Change who can see and use a setup. + + Args: + setup_id: The setup whose visibility to change. + visibility: "public" (everyone), "private" (owner only) or + "internal" (whole organisation). + + Returns: + The canonical envelope; ``output`` = the setup with its updated visibility. + """ + ok, result = await self._guard( + "change_visibility", + self._setup.change_visibility({"setup_id": setup_id, "visibility": visibility}), + ) + if not ok: + return result + await self._invalidate() + return self._ok(self._jsonable(result), tool="change_visibility") diff --git a/src/digitalkin/community/agno/toolkits/tool_loader.py b/src/digitalkin/community/agno/toolkits/tool_loader.py new file mode 100644 index 00000000..d7225a63 --- /dev/null +++ b/src/digitalkin/community/agno/toolkits/tool_loader.py @@ -0,0 +1,139 @@ +"""Toolkit for dynamically loading a discovered setup as a live, callable tool. + +``use_setup`` is an external-execution tool: when the model invokes it, Agno pauses +the run (rather than executing an entrypoint), handing control to +:class:`~digitalkin.community.agno.hitl.AgnoHitlRunner`. The runner calls +:meth:`ToolLoaderTools.load`, which resolves the setup into a +:class:`~digitalkin.community.agno.module_toolkit.ModuleToolkit`, appends it to the +live ``base_tools`` list the agent's tools factory closes over, and auto-continues — +so discover → load → use looks like a single turn to the user. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from digitalkin.community.agno.toolkits.base import DkToolkit +from digitalkin.grpc_servers.exceptions import PermissionDeniedError +from digitalkin.logger import logger + +if TYPE_CHECKING: + from collections.abc import Callable + + from digitalkin.models.module import ModuleContext + + +class ToolLoaderTools(DkToolkit): + """Expose ``use_setup`` — an external-execution tool that loads a setup on demand. + + The tool itself never executes: it is registered as external-execution so the run + pauses when the model calls it. The bound :class:`AgnoHitlRunner` then invokes + :meth:`load` and auto-continues with the enlarged tool list. + """ + + def __init__(self, context: ModuleContext | None = None) -> None: + """Register the ``use_setup`` external-execution tool. + + Args: + context: Module context; supplies ``resolve_tool`` and AG-UI notifications. + """ + super().__init__( + name="tool_loader_tools", + tools=[self.use_setup], + context=context, + external_execution_required_tools=[self.use_setup.__name__], + ) + # The live list the agent's tools factory splats; bound by DefaultToolkits.build. + self._base_tools: list[Any] | None = None + + @property + def tool_name(self) -> str: + """The external tool name the runner pauses on and routes to :meth:`load`.""" + return self.use_setup.__name__ + + def bind_tools(self, base_tools: list[Any]) -> None: + """Bind the live tool list that :meth:`load` appends newly-loaded tools to. + + Args: + base_tools: The exact list the agent's ``make_tools_factory`` closes over, + so an appended toolkit is visible on the next run. + """ + self._base_tools = base_tools + + @staticmethod + def find(tools: list[Any] | Callable[..., list[Any]] | None) -> ToolLoaderTools | None: + """Locate the ToolLoaderTools instance within a tools list or factory. + + Args: + tools: The tools list, or a ``make_tools_factory`` callable. + + Returns: + The first ToolLoaderTools found, or ``None``. + """ + if callable(tools) and not isinstance(tools, list): + tools = tools(None) + if not isinstance(tools, list): + return None + for tool in tools: + if isinstance(tool, ToolLoaderTools): + return tool + return None + + async def use_setup(self, setup_id: str) -> str: + """Load a discovered setup as a live tool you can call immediately. + + Pass a ``setup_id`` returned by ``search_setups`` to make that tool available for + the rest of this conversation. The tool is loaded right away — you do NOT need to + ask the user — and you can call it in your very next step. This returns a short + confirmation (or an error if the setup could not be loaded). + + Args: + setup_id: The setup id (from ``search_setups``) to load as an invocable tool. + + Returns: + A confirmation that the tool is loaded, or an error message. + """ + # Never executed: registered as external-execution, so the run pauses here and + # AgnoHitlRunner calls load() instead. Kept for a correct LLM-facing schema. + return self._ok({"setup_id": setup_id, "status": "pending"}, tool="use_setup") + + async def load(self, setup_id: str) -> str: + """Resolve ``setup_id`` into a ModuleToolkit and append it to the live tool list. + + Called by the runner on a ``use_setup`` pause. Idempotent per ``setup_id`` (a tool + already loaded is not duplicated). Never raises — resolution/permission failures + return a message the model reads as the tool result. + + Args: + setup_id: The setup id to load as an invocable tool. + + Returns: + A short status string ("loaded: …" / "permission denied: …" / "could not load …"). + """ + if self._ctx is None or self._base_tools is None: + return "tool loading is unavailable in this context" + try: + info = await self._ctx.resolve_tool(setup_id) + except PermissionDeniedError: + return f"permission denied: cannot load setup {setup_id}" + except Exception as error: + logger.warning("ToolLoaderTools: failed to resolve setup %s: %s", setup_id, error) + return f"could not load setup {setup_id}" + if info is None: + return f"could not load setup {setup_id}: not found" + if not info.tools: + return f"could not load setup {setup_id}: module exposes no callable tools" + + # Imported here, not at module top: ModuleToolkit requires the optional agno + # dependency at import time (see its module docstring), while this toolkit must + # stay importable without it — same convention as the rest of community.agno. + from digitalkin.community.agno.module_toolkit import ModuleToolkit + + name = info.tool_name or info.module_name or info.slug + already = any( + isinstance(tool, ModuleToolkit) and tool.tool_module_info.setup_id == setup_id for tool in self._base_tools + ) + if not already: + self._base_tools.append(ModuleToolkit(self._ctx, info)) + await self._notify("tool_loaded", {"setup_id": setup_id, "tool_name": name}) + return f"loaded: '{name}' is now available as a tool; call it directly to use it" diff --git a/src/digitalkin/community/agno/toolkits/user_profile.py b/src/digitalkin/community/agno/toolkits/user_profile.py new file mode 100644 index 00000000..6d62c0a8 --- /dev/null +++ b/src/digitalkin/community/agno/toolkits/user_profile.py @@ -0,0 +1,59 @@ +"""Toolkit exposing the current user's profile to the agent.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from digitalkin.community.agno.toolkits.base import DkToolkit +from digitalkin.logger import logger +from digitalkin.services.user_profile.exceptions import UserProfileServiceError + +if TYPE_CHECKING: + from digitalkin.models.module import ModuleContext + from digitalkin.services.user_profile.user_profile_strategy import UserProfileStrategy + + +class UserProfileTools(DkToolkit): + """Toolkit that gives the agent access to the current user's profile. + + The profile is fetched lazily from the module's + :class:`~digitalkin.services.user_profile.UserProfileStrategy` on first use and + cached for the toolkit's lifetime. A service failure is NOT cached, so a + transient error is retried on the next call; a successful ``None`` (no profile) is. + """ + + def __init__(self, user_profile: UserProfileStrategy, context: ModuleContext | None = None) -> None: + """Initialize toolkit with the ``get_user_profile`` tool. + + Args: + user_profile: The module's user-profile service strategy. + context: Module context; enables AG-UI notifications via the base toolkit. + """ + self._user_profile = user_profile + self._profile: dict[str, Any] | None = None + self._loaded = False + super().__init__( + name="user_profile_tools", + tools=[self.get_user_profile], + context=context, + ) + + async def get_user_profile(self) -> str: + """Retrieve the current user's profile: name, email, subscription plan, and remaining credits. + + IMPORTANT: You do NOT know what credits represent, how they are consumed, + or what they correspond to in terms of usage. Never speculate, explain, or + invent information about credits. Simply report the raw values as-is. + + Returns: + The canonical envelope: ``{"output": , ...}`` or ``{"error": ...}``. + """ + if not self._loaded: + try: + self._profile = await self._user_profile.get_user_profile() + self._loaded = True + except UserProfileServiceError as error: + logger.warning("UserProfileTools: failed to fetch profile: %s", error) + if not self._profile: + return self._fail("user profile is not available", tool="get_user_profile") + return self._ok(self._profile, tool="get_user_profile") diff --git a/src/digitalkin/core/job_manager/base_job_manager.py b/src/digitalkin/core/job_manager/base_job_manager.py index 12d4cfa6..7643edfd 100644 --- a/src/digitalkin/core/job_manager/base_job_manager.py +++ b/src/digitalkin/core/job_manager/base_job_manager.py @@ -214,6 +214,8 @@ async def preload_instance( job_id: str | None = None, tool_cache: Any = None, callback: Callable | None = None, + setup: Any = None, + invalidate_setup: Callable[[], None] | None = None, ) -> tuple[Any, str, Callable]: """Build a module instance and run its idempotent ``prepare()``. diff --git a/src/digitalkin/core/job_manager/single_job_manager.py b/src/digitalkin/core/job_manager/single_job_manager.py index 489791d2..92c939b8 100644 --- a/src/digitalkin/core/job_manager/single_job_manager.py +++ b/src/digitalkin/core/job_manager/single_job_manager.py @@ -235,6 +235,8 @@ async def preload_instance( job_id: str | None = None, tool_cache: Any = None, callback: Callable | None = None, + setup: Any = None, + invalidate_setup: Callable[[], None] | None = None, ) -> tuple[Any, str, Callable]: """Build a module instance and run its idempotent ``prepare()``. @@ -250,6 +252,10 @@ async def preload_instance( job_id: Optional externally-provided job ID. tool_cache: Pre-resolved ToolCache. callback: Direct output callback; ``None`` wires the in-memory queue. + setup: Borrowed SetupStrategy (servicer's shared instance); wired + before ``prepare()`` so ``initialize()`` can build setup toolkits. + invalidate_setup: Callback clearing the servicer's setup cache after + an agent-driven setup edit; installed on ``context.callbacks``. Returns: ``(module, job_id, callback)``. @@ -268,6 +274,12 @@ async def preload_instance( timer.mark("factory_create") module.context.task_manager = self._redis_task_manager + # Borrowed services must be wired before prepare(): initialize() runs + # inside prepare() and is where modules build their toolkits. + if setup is not None: + module.context.setup = setup + if invalidate_setup is not None: + module.context.callbacks.invalidate_setup = invalidate_setup timer.mark("redis_task_manager") if callback is None: diff --git a/src/digitalkin/core/task_manager/module_runner.py b/src/digitalkin/core/task_manager/module_runner.py index b004e1b4..919b6857 100644 --- a/src/digitalkin/core/task_manager/module_runner.py +++ b/src/digitalkin/core/task_manager/module_runner.py @@ -177,6 +177,9 @@ async def _on_output(output_data: Any) -> None: input_data = self._servicer.module_class.create_input_model(input_dict) timer.mark("pydantic_input") + # Share the servicer's setup service (same instance + channel) so setup-CRUD + # toolkits can reach it; borrowed, so context cleanup never closes it. Wired + # inside preload_instance, before prepare()/initialize() builds the toolkits. module, job_id, callback = await self._servicer.job_manager.preload_instance( setup_data, mission_id=mission_id, @@ -186,6 +189,8 @@ async def _on_output(output_data: Any) -> None: job_id=task_id, tool_cache=tool_cache, callback=_on_output, + setup=self._servicer.setup, + invalidate_setup=self._servicer.invalidate_setup_cache, ) timer.mark("preload_join") diff --git a/src/digitalkin/grpc_servers/_base_server.py b/src/digitalkin/grpc_servers/_base_server.py index 7834d17b..5b149cea 100644 --- a/src/digitalkin/grpc_servers/_base_server.py +++ b/src/digitalkin/grpc_servers/_base_server.py @@ -3,6 +3,7 @@ import abc import asyncio import os +import sys from digitalkin.models.settings.profiling import get_profiling_settings @@ -196,10 +197,16 @@ def _create_server(self) -> GrpcServer: try: # noqa: PLW0717 grpc_compression = get_server_settings().grpc.compression.to_grpc() - try: - cpu_count = len(os.sched_getaffinity(0)) - logger.info("vCPU count: %d", cpu_count) - except (AttributeError, OSError): + # sched_getaffinity is Linux-only; the sys.platform guard lets mypy skip + # it as unreachable on other platforms without a type: ignore. + if sys.platform == "linux": + try: + cpu_count = len(os.sched_getaffinity(0)) + logger.info("vCPU count: %d", cpu_count) + except OSError: + cpu_count = os.cpu_count() or 1 + logger.info("CPU count: %d", cpu_count) + else: cpu_count = os.cpu_count() or 1 logger.info("CPU count: %d", cpu_count) diff --git a/src/digitalkin/grpc_servers/gateway_servicer.py b/src/digitalkin/grpc_servers/gateway_servicer.py index e23394db..81fb359b 100644 --- a/src/digitalkin/grpc_servers/gateway_servicer.py +++ b/src/digitalkin/grpc_servers/gateway_servicer.py @@ -1000,20 +1000,26 @@ async def _runner_fatal(code: str, message: str) -> None: # ``dial_back_close_grace_s`` for non-conforming consumers. response_iter = aiter(responses) while True: + grace = get_gateway_settings().dial_back_close_grace_s + if outgoing_done.is_set(): + read: Any = asyncio.wait_for(anext(response_iter), timeout=grace) + else: + # Bound a read parked before ``outgoing_done`` fires: once the outputs + # (incl. a fatal stream.error+EOS) finish draining, switch to the close-grace + # wait instead of parking to the ``dial_back_max_lifetime_s`` RPC deadline. + pending = asyncio.ensure_future(anext(response_iter)) + drained = asyncio.ensure_future(outgoing_done.wait()) + await asyncio.wait({pending, drained}, return_when=asyncio.FIRST_COMPLETED) + drained.cancel() + read = pending if pending.done() else asyncio.wait_for(pending, timeout=grace) try: - if outgoing_done.is_set(): - upstream = await asyncio.wait_for( - anext(response_iter), - timeout=get_gateway_settings().dial_back_close_grace_s, - ) - else: - upstream = await anext(response_iter) + upstream = await read except StopAsyncIteration: break except asyncio.TimeoutError: logger.info( "Consumer didn't close response stream within %.1fs after stream.end — closing BiDi", - get_gateway_settings().dial_back_close_grace_s, + grace, extra=log_extra, ) break diff --git a/src/digitalkin/grpc_servers/module_server.py b/src/digitalkin/grpc_servers/module_server.py index 8b18e0a2..0307ef60 100644 --- a/src/digitalkin/grpc_servers/module_server.py +++ b/src/digitalkin/grpc_servers/module_server.py @@ -251,6 +251,8 @@ async def _init_and_register(self) -> None: address=advertise_address, port=get_server_settings().channel.port, version=version, + module_type=self.module_class.registry_type, + documentation=self.module_class.build_registry_documentation(), ) if not result: diff --git a/src/digitalkin/grpc_servers/module_servicer.py b/src/digitalkin/grpc_servers/module_servicer.py index 1b0552cc..9fd31556 100644 --- a/src/digitalkin/grpc_servers/module_servicer.py +++ b/src/digitalkin/grpc_servers/module_servicer.py @@ -137,6 +137,8 @@ def invalidate_setup_cache(self) -> None: def invalidate_tool_cache(self) -> None: """Clear tool cache. Next request re-resolves tool definitions.""" + if self._tool_cache_by_setup: + logger.info("tool cache invalidated, dropped setups: %s", list(self._tool_cache_by_setup)) self._tool_cache_by_setup.clear() def get_tool_cache(self, setup_id: str) -> Any | None: @@ -154,6 +156,7 @@ def get_tool_cache(self, setup_id: str) -> Any | None: value, expires_at = entry if time.monotonic() >= expires_at: self._tool_cache_by_setup.pop(setup_id, None) + logger.debug("tool cache expired for setup '%s'", setup_id) return None return value @@ -167,10 +170,14 @@ def set_tool_cache(self, setup_id: str, value: Any) -> None: if len(self._tool_cache_by_setup) >= get_module_servicer_settings().setup_cache_max: oldest_key = next(iter(self._tool_cache_by_setup)) del self._tool_cache_by_setup[oldest_key] - self._tool_cache_by_setup[setup_id] = ( - value, - time.monotonic() + get_gateway_settings().queue.toolkit_cache_ttl_s, - ) + logger.warning( + "tool cache full (%d), evicting setup '%s'", + get_module_servicer_settings().setup_cache_max, + oldest_key, + ) + ttl_s = get_gateway_settings().queue.toolkit_cache_ttl_s + self._tool_cache_by_setup[setup_id] = (value, time.monotonic() + ttl_s) + logger.debug("tool cache set for setup '%s' (ttl %.0fs)", setup_id, ttl_s) async def get_or_build_tool_cache( self, @@ -188,9 +195,11 @@ async def get_or_build_tool_cache( """ cached = self.get_tool_cache(setup_id) if cached is not None: + logger.debug("tool cache hit for setup '%s'", setup_id) return cached inflight = self._tool_cache_inflight.get(setup_id) if inflight is not None: + logger.debug("tool cache build in flight for setup '%s', awaiting", setup_id) return await inflight loop = asyncio.get_event_loop() fut: asyncio.Future[Any] = loop.create_future() @@ -205,6 +214,7 @@ async def get_or_build_tool_cache( # ``module_servicer.py:367``) clears the entry. if value is not None: self.set_tool_cache(setup_id, value) + logger.info("tool cache built for setup '%s'", setup_id) fut.set_result(value) except Exception as exc: fut.set_exception(exc) diff --git a/src/digitalkin/mixins/storage_mixin.py b/src/digitalkin/mixins/storage_mixin.py index 12a91012..c35fbe1b 100644 --- a/src/digitalkin/mixins/storage_mixin.py +++ b/src/digitalkin/mixins/storage_mixin.py @@ -3,6 +3,7 @@ from typing import Any, Literal from digitalkin.models.module.module_context import ModuleContext +from digitalkin.models.services.storage import DataType from digitalkin.services.storage.storage_strategy import StorageRecord @@ -36,7 +37,7 @@ async def store_storage( Raises: StorageServiceError: If storage operation fails """ - return await context.storage.store(collection, record_id, data, data_type=data_type) + return await context.storage.store(collection, record_id, data, data_type=DataType[data_type]) @staticmethod async def read_storage(context: ModuleContext, collection: str, record_id: str) -> StorageRecord | None: @@ -101,4 +102,4 @@ async def upsert_storage( Raises: StorageServiceError: If upsert operation fails """ - return await context.storage.upsert(collection, record_id, data, data_type=data_type) + return await context.storage.upsert(collection, record_id, data, data_type=DataType[data_type]) diff --git a/src/digitalkin/models/module/module_context.py b/src/digitalkin/models/module/module_context.py index 352c1075..9b395d10 100644 --- a/src/digitalkin/models/module/module_context.py +++ b/src/digitalkin/models/module/module_context.py @@ -13,11 +13,14 @@ from digitalkin.models.module.tool_cache import ToolCache, ToolDefinition, ToolModuleInfo from digitalkin.models.settings.module import get_module_settings from digitalkin.services.communication.communication_strategy import CommunicationStrategy +from digitalkin.services.communication.exceptions import ToolCallError from digitalkin.services.cost.cost_strategy import CostStrategy from digitalkin.services.filesystem.filesystem_strategy import FilesystemStrategy from digitalkin.services.identity.identity_strategy import IdentityStrategy +from digitalkin.services.registry.exceptions import RegistryModuleNotFoundError from digitalkin.services.registry.registry_strategy import RegistryStrategy from digitalkin.services.secret.secret_strategy import SecretStrategy +from digitalkin.services.setup.setup_strategy import SetupStrategy from digitalkin.services.storage.storage_strategy import StorageStrategy from digitalkin.services.task_manager.task_manager_strategy import TaskManagerStrategy from digitalkin.services.user_profile.user_profile_strategy import UserProfileStrategy @@ -97,6 +100,7 @@ class ModuleContext: identity: IdentityStrategy registry: RegistryStrategy secret: SecretStrategy + setup: SetupStrategy | None storage: StorageStrategy task_manager: TaskManagerStrategy | None user_profile: UserProfileStrategy @@ -129,6 +133,7 @@ def __init__( # All service strategies are mandatory constructor args # noqa: P borrowed: frozenset[str] | None = None, shared: dict[str, Any] | None = None, task_manager: TaskManagerStrategy | None = None, + setup: SetupStrategy | None = None, ) -> None: """Register mandatory services, session, metadata and callbacks. @@ -142,6 +147,7 @@ def __init__( # All service strategies are mandatory constructor args # noqa: P storage: StorageStrategy. user_profile: UserProfileStrategy. task_manager: Optional, injected by SingleJobManager (RedisTaskManager). + setup: Optional setup service, borrowed from the servicer (shared channel). metadata: dict defining differents Module metadata. helpers: dict different user defined helpers. session: dict referring the session IDs or informations. @@ -151,13 +157,14 @@ def __init__( # All service strategies are mandatory constructor args # noqa: P borrowed: Strategy names that are shared singletons — skip .close() on cleanup. shared: Server-lifetime cache shared across all module instances. """ - self._borrowed = (borrowed or frozenset()) | frozenset({"task_manager"}) + self._borrowed = (borrowed or frozenset()) | frozenset({"task_manager", "setup"}) self.communication = communication self.cost = cost self.filesystem = filesystem self.identity = identity self.registry = registry self.secret = secret + self.setup = setup self.storage = storage self.task_manager = task_manager self.user_profile = user_profile @@ -322,6 +329,66 @@ def create_tool_functions( return result + async def resolve_tool(self, setup_id: str) -> ToolModuleInfo | None: + """Resolve a registry ``setup_id`` into a ``ToolModuleInfo`` and cache it. + + On-demand loader for a discovered tool. ``registry.get_setup`` always runs + first — it is the permission gate, and the tool cache is shared across + missions of the same agent setup, so a cache hit must never skip authz. + The cache only short-circuits the module discovery + schema fetch. + Permission denials propagate so callers can surface them distinctly. + + Args: + setup_id: The registry setup id to load as an invocable tool. + + Returns: + The resolved ``ToolModuleInfo`` (also added to the tool cache), or ``None`` + if the setup or its module could not be found. + + Raises: + PermissionDeniedError: If the registry/communication call is not permitted. + """ + setup = await self.registry.get_setup(setup_id) + if setup is None or not setup.module_id: + logger.warning( + "resolve_tool: setup '%s' not found or has no module", setup_id, extra=self.session.current_ids() + ) + return None + cached = self.tool_cache.entries.get(setup_id) + if cached is not None: + logger.debug( + "resolve_tool: cache hit for setup '%s' (authz re-checked)", setup_id, extra=self.session.current_ids() + ) + return cached + try: + info = await self.registry.discover_by_id(setup.module_id) + except RegistryModuleNotFoundError: + logger.warning( + "resolve_tool: module '%s' for setup '%s' not found in registry", + setup.module_id, + setup_id, + extra=self.session.current_ids(), + ) + return None + if info is None: + logger.warning( + "resolve_tool: module '%s' for setup '%s' not found in registry", + setup.module_id, + setup_id, + extra=self.session.current_ids(), + ) + return None + tool_info = await ToolModuleInfo.from_module_info(info, setup_id, setup.name, self.communication) + self.tool_cache.add(tool_info) + logger.info( + "resolve_tool: resolved setup '%s' -> module '%s' (%d tools), cached", + setup_id, + setup.module_id, + len(tool_info.tools), + extra=self.session.current_ids(), + ) + return tool_info + @staticmethod def _create_single_tool_function( communication: CommunicationStrategy, @@ -358,7 +425,14 @@ async def tool_function( mission_id=session.mission_id, metadata=grpc_metadata, ): - yield json_format.MessageToDict(output_proto) + frame = json_format.MessageToDict(output_proto) + root = frame.get("root") + # A fatal stream.error (e.g. SETUP_ACCESS_DENIED) must abort the tool call, + # not surface as a benign result — otherwise the parent run never terminates. + if isinstance(root, dict) and root.get("protocol") == "stream.error" and root.get("fatal"): + msg = f"[{root.get('code', '')}] {root.get('message', '')}" + raise ToolCallError(msg) + yield frame tool_function.__name__ = tool_module_info.slug + "__" + tool_def.name tool_function.__doc__ = tool_def.description diff --git a/src/digitalkin/models/services/filesystem.py b/src/digitalkin/models/services/filesystem.py new file mode 100644 index 00000000..f09f5faa --- /dev/null +++ b/src/digitalkin/models/services/filesystem.py @@ -0,0 +1,19 @@ +"""Filesystem service models.""" + +from enum import Enum + + +class ContextFile(Enum): + """Owner/scope of a file in the filesystem service. + + Mirrors the filesystem proto context kinds. MISSIONS/SETUP are the read/write + owner contexts this strategy operates on; USERS/ORGANIZATIONS are read-only + cross-owner scopes whose concrete id is resolved server-side from the request + metadata (the client sends only the kind). + """ + + UNSPECIFIED = "unspecified" + MISSIONS = "mission" + SETUP = "setup" + USERS = "user" + ORGANIZATIONS = "organization" diff --git a/src/digitalkin/models/services/registry.py b/src/digitalkin/models/services/registry.py index d43c4311..a1da0fea 100644 --- a/src/digitalkin/models/services/registry.py +++ b/src/digitalkin/models/services/registry.py @@ -3,7 +3,9 @@ from enum import Enum from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, field_validator + +from digitalkin.logger import logger class RegistryModuleStatus(str, Enum): @@ -16,11 +18,16 @@ class RegistryModuleStatus(str, Enum): class RegistryModuleType(str, Enum): - """Module type in the registry.""" + """Module type in the registry. + + Member names mirror the proto ``ModuleType`` enum (minus the ``MODULE_TYPE_`` + prefix): they are looked up by name from the wire value, so they must match. + """ UNSPECIFIED = "unspecified" ARCHETYPE = "archetype" - TOOL = "tool" + TOOL_MODULE = "tool_module" + SERVICE = "service" class ModuleInfo(BaseModel): @@ -35,6 +42,32 @@ class ModuleInfo(BaseModel): documentation: str | None = None status: RegistryModuleStatus | None = None + @field_validator("module_type", mode="before") + @classmethod + def _coerce_legacy_module_type(cls, value: object) -> object: + """Normalize the legacy 'tool'/'kin' vocabulary written by older SDK releases. + + Setup contents persisted before the enum aligned on the proto names carry + ``resolved_tools`` entries with ``module_type: "tool"``; the config-setup flow + strips and rebuilds the field, so tolerated payloads self-heal on the next + reconfiguration. + + Args: + value: The raw module_type value. + + Returns: + The normalized enum member, or the value unchanged. + """ + if value == "tool": + # TODO(validate): remove marker once legacy setups are purged in prod + logger.warning("[VALIDATE MTYPE] legacy module_type 'tool' normalized to 'tool_module'") + return RegistryModuleType.TOOL_MODULE + if value == "kin": + # TODO(validate): remove marker once legacy setups are purged in prod + logger.warning("[VALIDATE MTYPE] legacy module_type 'kin' normalized to 'archetype'") + return RegistryModuleType.ARCHETYPE + return value + class RegistrySetupStatus(str, Enum): """Setup status in the registry.""" @@ -72,6 +105,28 @@ class SetupInfo(BaseModel): owner_id: str | None = None card_id: str | None = None module_id: str | None = None + module_name: str | None = None + module_type: RegistryModuleType | None = None setup_version_id: str | None = None setup_version: str | None = None config: dict[str, Any] | None = None + + +class SetupSummary(BaseModel): + """Search-safe setup view — the shape returned by ``search_setups``. + + Deliberately has no ``config`` field: a setup's secrets can never be + serialized from a search result. Use ``get_setup`` for the full ``SetupInfo``. + """ + + setup_id: str + name: str + documentation: str | None = None + status: RegistrySetupStatus | None = None + visibility: RegistryVisibility | None = None + organization_id: str | None = None + module_id: str | None = None + module_name: str | None = None + module_type: RegistryModuleType | None = None + setup_version_id: str | None = None + setup_version: str | None = None diff --git a/src/digitalkin/models/services/storage.py b/src/digitalkin/models/services/storage.py index fa447254..783a54fd 100644 --- a/src/digitalkin/models/services/storage.py +++ b/src/digitalkin/models/services/storage.py @@ -51,3 +51,26 @@ class DataType(Enum): VIEW = "VIEW" LOGS = "LOGS" OTHER = "OTHER" + + +class ContextStorage(Enum): + """Enum defining the context of data in storage.""" + + UNSPECIFIED = "unspecified" + MISSIONS = "missions" + SETUP_VERSIONS = "setup_versions" + USERS = "users" + ORGANIZATIONS = "organizations" + + +class Visibility(Enum): + """Read-access scope of a record, mirroring the storage proto by integer value. + + Ownership (who may edit) stays keyed on the record context; this only governs + who may read. UNSPECIFIED lets the storage service apply its server-side default. + """ + + UNSPECIFIED = 0 + PUBLIC = 1 + PRIVATE = 2 + INTERNAL = 3 diff --git a/src/digitalkin/models/settings/registry.py b/src/digitalkin/models/settings/registry.py new file mode 100644 index 00000000..f5f2e3c2 --- /dev/null +++ b/src/digitalkin/models/settings/registry.py @@ -0,0 +1,29 @@ +"""Registry-scope runtime settings.""" + +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class RegistrySettings(BaseSettings): + """Registry client runtime configuration.""" + + model_config = SettingsConfigDict(env_prefix="DIGITALKIN_REGISTRY_", case_sensitive=False) + + search_timeout_s: float = Field( + default=10.0, + description="Per-call deadline for agent-facing registry searches (shorter than the global gRPC default).", + ) + + +@lru_cache(maxsize=1) +def get_registry_settings() -> RegistrySettings: + """Process-wide ``RegistrySettings`` singleton. + + Tests must call ``get_registry_settings.cache_clear()`` after mutating env. + + Returns: + The shared ``RegistrySettings`` instance. + """ + return RegistrySettings() diff --git a/src/digitalkin/modules/_base_module.py b/src/digitalkin/modules/_base_module.py index feac4da7..1561bda9 100644 --- a/src/digitalkin/modules/_base_module.py +++ b/src/digitalkin/modules/_base_module.py @@ -22,6 +22,7 @@ from digitalkin.models.module.select_schema import SelectSchema from digitalkin.models.module.tool_cache import ToolCache from digitalkin.models.module.utility import EndOfStreamOutput, UtilityProtocol +from digitalkin.models.services.registry import RegistryModuleType from digitalkin.models.services.storage import BaseRole from digitalkin.models.settings.module import get_module_settings from digitalkin.modules.trigger_handler import TriggerHandler @@ -45,7 +46,7 @@ class BaseModule( # Module SDK base class requires many public methods # noqa: """BaseModule is the abstract base for all modules in the DigitalKin SDK.""" name: str - description: str + description: str = "" setup_format: type[SetupModelT] input_format: type[InputModelT] @@ -59,6 +60,7 @@ class BaseModule( # Module SDK base class requires many public methods # noqa: _extended_input_format: ClassVar[type[DataModel] | None] = None _shared: ClassVar[dict[str, Any]] = {} _builds_tool_cache: ClassVar[bool] = False + registry_type: ClassVar[RegistryModuleType] = RegistryModuleType.UNSPECIFIED """Only ArchetypeModule (tool-composing) resolves a tool cache.""" @classmethod @@ -234,6 +236,29 @@ async def get_select_input_format(cls) -> str: return json.dumps(select_schema, indent=2) + @classmethod + def build_registry_documentation(cls) -> str: + """Assemble the registry documentation: author description + LLM-readable trigger table. + + Enforces an author-written description of the archetype/tool specificity + (``cls.description``, falling back to ``metadata['description']``), then appends a + markdown table of the module's non-utility triggers for registry index search. + + Returns: + Markdown documentation string sent as the registration ``documentation``. + + Raises: + ValueError: If the module declares no description. + """ + description = (cls.description or cls.metadata.get("description", "")).strip() + if not description: + msg = f"{cls.__name__} must define a non-empty 'description' for registry indexing" + raise ValueError(msg) + protocols = cls.triggers_discoverer.get_registered_protocols_with_info(exclude_utility=True) + rows = "\n".join(f"| {protocol} | {desc} |" for protocol, desc in sorted(protocols.items())) + table = f"| Trigger | Description |\n| --- | --- |\n{rows}" if rows else "_No triggers._" + return f"{description}\n\n## Triggers\n\n{table}" + @classmethod async def get_output_format(cls, *, llm_format: bool) -> str: """Get the JSON schema of the output format model. diff --git a/src/digitalkin/modules/archetype_module.py b/src/digitalkin/modules/archetype_module.py index 63e118ea..10d8403f 100644 --- a/src/digitalkin/modules/archetype_module.py +++ b/src/digitalkin/modules/archetype_module.py @@ -9,6 +9,7 @@ SecretModelT, SetupModelT, ) +from digitalkin.models.services.registry import RegistryModuleType from digitalkin.modules._base_module import BaseModule @@ -25,3 +26,4 @@ class ArchetypeModule( # Archetype modules compose tools — they resolve a tool cache. See BaseModule. _builds_tool_cache: ClassVar[bool] = True + registry_type: ClassVar[RegistryModuleType] = RegistryModuleType.ARCHETYPE diff --git a/src/digitalkin/modules/tool_module.py b/src/digitalkin/modules/tool_module.py index 02a2a74d..9f26a638 100644 --- a/src/digitalkin/modules/tool_module.py +++ b/src/digitalkin/modules/tool_module.py @@ -1,6 +1,7 @@ """ToolModule extends BaseModule to implement specific module types.""" from abc import ABC +from typing import ClassVar from digitalkin.models.module.module_types import ( InputModelT, @@ -8,6 +9,7 @@ SecretModelT, SetupModelT, ) +from digitalkin.models.services.registry import RegistryModuleType from digitalkin.modules._base_module import BaseModule # Private module import for SDK subclass @@ -21,3 +23,5 @@ class ToolModule( ABC, ): """ToolModule extends BaseModule to implement specific module types.""" + + registry_type: ClassVar[RegistryModuleType] = RegistryModuleType.TOOL_MODULE diff --git a/src/digitalkin/services/communication/exceptions.py b/src/digitalkin/services/communication/exceptions.py index 7e6a57ae..f5122b91 100644 --- a/src/digitalkin/services/communication/exceptions.py +++ b/src/digitalkin/services/communication/exceptions.py @@ -11,3 +11,7 @@ class M2MTargetUnavailable(RuntimeError): # noqa: N818 # public API name, pred class M2MCallTimeout(RuntimeError): # noqa: N818 # public API name, predates the refactor """``output_queue.get()`` exceeded ``call_timeout_s`` waiting for a target output.""" + + +class ToolCallError(RuntimeError): + """A called tool module returned a fatal ``stream.error``; message carries ``[CODE] message``.""" diff --git a/src/digitalkin/services/filesystem/default_filesystem.py b/src/digitalkin/services/filesystem/default_filesystem.py index 956d07e2..65293e5d 100644 --- a/src/digitalkin/services/filesystem/default_filesystem.py +++ b/src/digitalkin/services/filesystem/default_filesystem.py @@ -9,6 +9,7 @@ from anyio import Path as AsyncPath from digitalkin.logger import logger +from digitalkin.models.services.filesystem import ContextFile from digitalkin.services.filesystem.exceptions import FilesystemServiceError from digitalkin.services.filesystem.filesystem_strategy import ( FileFilter, @@ -206,7 +207,7 @@ async def get_files( async def get_file( self, file_id: str, - context: Literal["mission", "setup"] = "mission", # noqa: ARG002 + context: ContextFile = ContextFile.MISSIONS, # noqa: ARG002 *, include_content: bool = False, ) -> FilesystemRecord: diff --git a/src/digitalkin/services/filesystem/filesystem_strategy.py b/src/digitalkin/services/filesystem/filesystem_strategy.py index 952b6e4b..ca7b4981 100644 --- a/src/digitalkin/services/filesystem/filesystem_strategy.py +++ b/src/digitalkin/services/filesystem/filesystem_strategy.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field +from digitalkin.models.services.filesystem import ContextFile from digitalkin.services.base_strategy import BaseStrategy @@ -29,8 +30,9 @@ class FilesystemRecord(BaseModel): class FileFilter(BaseModel): """Filter criteria for querying files.""" - context: Literal["mission", "setup"] = Field( - default="mission", description="The context of the files (mission or setup)" + context: ContextFile = Field( + default=ContextFile.MISSIONS, + description="The context of the files: mission/setup (owner) or user/organization (read-only cross-owner)", ) names: list[str] | None = Field(default=None, description="Filter by file names (exact matches)") file_ids: list[str] | None = Field(default=None, description="Filter by file IDs") @@ -129,7 +131,7 @@ async def upload_files( async def get_file( self, file_id: str, - context: Literal["mission", "setup"] = "mission", + context: ContextFile = ContextFile.MISSIONS, *, include_content: bool = False, ) -> FilesystemRecord: @@ -141,7 +143,7 @@ async def get_file( Args: file_id: The ID of the file to be retrieved - context: The context of the files (mission or setup) + context: The context of the file (mission/setup, or user/organization for cross-owner reads) include_content: Whether to include file content in response Returns: diff --git a/src/digitalkin/services/filesystem/grpc_filesystem.py b/src/digitalkin/services/filesystem/grpc_filesystem.py index e2dd7a18..cff4e543 100644 --- a/src/digitalkin/services/filesystem/grpc_filesystem.py +++ b/src/digitalkin/services/filesystem/grpc_filesystem.py @@ -10,6 +10,7 @@ from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin from digitalkin.logger import logger from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.services.filesystem import ContextFile from digitalkin.services.filesystem.exceptions import FilesystemServiceError from digitalkin.services.filesystem.filesystem_strategy import ( FileFilter, @@ -79,6 +80,35 @@ def _file_proto_to_data(file: filesystem_pb2.File) -> FilesystemRecord: content=file.content, ) + @staticmethod + def _context_enum(context: ContextFile) -> filesystem_pb2.ContextFile: + """Map a context kind to the wire's context-kind enum. + + Since dev4 the request carries only the kind; the concrete id is resolved + server-side from the request metadata stamped by ``RequestIdClientInterceptor``. + USERS/ORGANIZATIONS are read-only cross-owner scopes — only the kind is sent; + the server derives the owning user/organization from the request context (no id + is transmitted by the client). + + Args: + context: The context kind. + + Returns: + The matching ``ContextFile`` wire enum, ``CONTEXT_UNSPECIFIED`` otherwise. + """ + # TODO(validate): remove after prod validation + # [VALIDATE CTXENUM] server resolves the concrete id from metadata + match context: + case ContextFile.SETUP: + return filesystem_pb2.CONTEXT_SETUP + case ContextFile.MISSIONS: + return filesystem_pb2.CONTEXT_MISSIONS + case ContextFile.USERS: + return filesystem_pb2.CONTEXT_USERS + case ContextFile.ORGANIZATIONS: + return filesystem_pb2.CONTEXT_ORGANIZATIONS + return filesystem_pb2.CONTEXT_UNSPECIFIED + def _filter_to_proto(self, filters: FileFilter) -> filesystem_pb2.FileFilter: """Convert a FileFilter to a FileFilter proto message. @@ -88,19 +118,13 @@ def _filter_to_proto(self, filters: FileFilter) -> filesystem_pb2.FileFilter: Returns: filesystem_pb2.FileFilter: The converted FileFilter proto message """ - context_id = "unknown" - match filters.context: - case "setup": - context_id = self.setup_id - case "mission": - context_id = self.mission_id return filesystem_pb2.FileFilter( **filters.model_dump(exclude={"file_types", "status", "context"}), file_types=[self._file_type_to_enum(file_type) for file_type in filters.file_types] if filters.file_types else None, status=self._file_status_to_enum(filters.status) if filters.status else None, - context=context_id, + context=self._context_enum(filters.context), ) def __init__( @@ -152,7 +176,7 @@ async def upload_files( metadata_struct.update(file.metadata) upload_files.append( filesystem_pb2.UploadFileData( - context=self.mission_id, + context=filesystem_pb2.CONTEXT_MISSIONS, name=file.name, file_type=self._file_type_to_enum(file.file_type), content_type=file.content_type or "application/octet-stream", @@ -171,7 +195,7 @@ async def upload_files( async def get_file( self, file_id: str, - context: Literal["mission", "setup"] = "mission", + context: ContextFile = ContextFile.MISSIONS, *, include_content: bool = False, ) -> FilesystemRecord: @@ -179,7 +203,7 @@ async def get_file( Args: file_id: The ID of the file to be retrieved - context: The context of the files (mission or setup) + context: The context of the file (mission/setup, or user/organization for cross-owner reads) include_content: Whether to include file content in response Returns: @@ -188,15 +212,10 @@ async def get_file( Raises: FilesystemServiceError: If there is an error retrieving the file """ - match context: - case "setup": - context_id = self.setup_id - case "mission": - context_id = self.mission_id logger.debug("debug:get_file file_id=%s context=%s", file_id, context) async with self.handle_grpc_errors("GetFile", FilesystemServiceError): request = filesystem_pb2.GetFileRequest( - context=context_id, + context=self._context_enum(context), file_id=file_id, include_content=include_content, ) @@ -244,7 +263,7 @@ async def update_file( """ async with self.handle_grpc_errors("UpdateFile", FilesystemServiceError): request = filesystem_pb2.UpdateFileRequest( - context=self.mission_id, + context=filesystem_pb2.CONTEXT_MISSIONS, file_id=file_id, content=content, file_type=self._file_type_to_enum(file_type) if file_type else None, @@ -279,7 +298,7 @@ async def delete_files( logger.debug("debug:delete_files permanent=%s force=%s", permanent, force) async with self.handle_grpc_errors("DeleteFiles", FilesystemServiceError): request = filesystem_pb2.DeleteFilesRequest( - context=self.mission_id, + context=filesystem_pb2.CONTEXT_MISSIONS, filters=self._filter_to_proto(filters), permanent=permanent, force=force, @@ -309,14 +328,9 @@ async def get_files( Returns: tuple[list[FilesystemRecord], int]: List of files and total count """ - match filters.context: - case "setup": - context_id = self.setup_id - case "mission": - context_id = self.mission_id async with self.handle_grpc_errors("GetFiles", FilesystemServiceError): request = filesystem_pb2.GetFilesRequest( - context=context_id, + context=self._context_enum(filters.context), filters=self._filter_to_proto(filters), include_content=include_content, list_size=list_size, diff --git a/src/digitalkin/services/registry/default_registry.py b/src/digitalkin/services/registry/default_registry.py index f5df1888..120df57c 100644 --- a/src/digitalkin/services/registry/default_registry.py +++ b/src/digitalkin/services/registry/default_registry.py @@ -6,6 +6,10 @@ ModuleInfo, RegistryModuleStatus, RegistryModuleType, + RegistrySetupStatus, + RegistryVisibility, + SetupInfo, + SetupSummary, ) from digitalkin.services.registry.exceptions import RegistryModuleNotFoundError from digitalkin.services.registry.registry_models import ModuleStatusInfo @@ -16,9 +20,10 @@ class DefaultRegistry(RegistryStrategy): """Default registry strategy using in-memory storage.""" def __init__(self, *args: Any, **kwargs: Any) -> None: - """Initialize with per-instance module store.""" + """Initialize with per-instance module and setup stores.""" super().__init__(*args, **kwargs) self._modules: dict[str, ModuleInfo] = {} + self._setups: dict[str, SetupInfo] = {} async def wait_for_ready(self, timeout: float = 1.0) -> bool: # noqa: ARG002, PLR6301 """Local registry is always ready (in-memory store). @@ -51,15 +56,16 @@ async def search( self, name: str | None = None, module_type: str | None = None, - organization_id: str # noqa: ARG002 - | None = None, # Strategy interface parameter, not used in local implementation + limit: int = 20, + offset: int = 0, ) -> list[ModuleInfo]: - """Search for modules by criteria. + """Search the module catalog (module blueprints; needs a setup to be invocable). Args: - name: Filter by name (partial match). - module_type: Filter by type (archetype, tool). - organization_id: Filter by organization (not used in local storage). + name: Case-insensitive free text matched against module name AND documentation. + module_type: Filter by type (archetype, tool_module, service). + limit: Max results (1-100). + offset: Pagination offset. Returns: List of matching modules. @@ -67,12 +73,15 @@ async def search( results = list(self._modules.values()) if name: - results = [m for m in results if name in m.module_name] + needle = name.lower() + results = [ + m for m in results if needle in m.module_name.lower() or needle in (m.documentation or "").lower() + ] if module_type: results = [m for m in results if m.module_type == module_type] - return results + return results[offset : offset + limit] async def get_status(self, module_id: str) -> ModuleStatusInfo: """Get module status. @@ -101,6 +110,8 @@ async def register( address: str, port: int, version: str, + module_type: RegistryModuleType = RegistryModuleType.UNSPECIFIED, + documentation: str = "", ) -> ModuleInfo | None: """Register a module with the registry. @@ -111,6 +122,8 @@ async def register( address: Network address. port: Network port. version: Module version. + module_type: Declared module type; UNSPECIFIED preserves the existing record's type. + documentation: Internal documentation for registry index search. Returns: ModuleInfo if successful, None otherwise. @@ -118,11 +131,14 @@ async def register( existing = self._modules.get(module_id) self._modules[module_id] = ModuleInfo( module_id=module_id, - module_type=existing.module_type if existing else RegistryModuleType.UNSPECIFIED, + module_type=module_type + if module_type != RegistryModuleType.UNSPECIFIED + else (existing.module_type if existing else RegistryModuleType.UNSPECIFIED), address=address, port=port, version=version, module_name=existing.module_name if existing else module_id, + documentation=documentation or (existing.documentation if existing else None), status=RegistryModuleStatus.ACTIVE, ) return self._modules[module_id] @@ -169,9 +185,78 @@ async def deregister(self, module_id: str) -> bool: return True return False - async def get_setup(self, setup_id: str) -> None: - """Get setup info (not supported in default registry). + async def get_setup(self, setup_id: str) -> SetupInfo | None: + """Get setup info from the in-memory store. Args: setup_id: The setup identifier. + + Returns: + SetupInfo if present, None otherwise. + """ + return self._setups.get(setup_id) + + def add_setup(self, setup: SetupInfo) -> None: + """Add a setup to the in-memory store (helper for testing). + + Args: + setup: The setup to store, keyed by its setup_id. + """ + self._setups[setup.setup_id] = setup + + async def search_setups( + self, + query: str | None = None, + setup_ids: list[str] | None = None, + module_ids: list[str] | None = None, + module_types: list[RegistryModuleType] | None = None, + statuses: list[RegistrySetupStatus] | None = None, + visibilities: list[RegistryVisibility] | None = None, + limit: int = 20, + offset: int = 0, + ) -> list[SetupSummary]: + """Search the setup catalog (configured, invocable module instances). + + Args: + query: Case-insensitive free text matched against setup name AND documentation. + setup_ids: Restrict to these setup ids. + module_ids: Restrict to setups backed by these modules. + module_types: Filter by backing module type (tool_module, archetype, service). + statuses: Filter by setup status. None = no filter. + visibilities: Filter by visibility. + limit: Max results (1-100). + offset: Pagination offset. + + Returns: + Matching setups as ``SetupSummary`` (no ``config`` field by construction). """ + results = list(self._setups.values()) + if setup_ids: + results = [s for s in results if s.setup_id in setup_ids] + if module_ids: + results = [s for s in results if s.module_id in module_ids] + if module_types: + results = [s for s in results if s.module_type in module_types] + if statuses: + results = [s for s in results if s.status in statuses] + if visibilities: + results = [s for s in results if s.visibility in visibilities] + if query: + needle = query.lower() + results = [s for s in results if needle in s.name.lower() or needle in (s.documentation or "").lower()] + return [ + SetupSummary( + setup_id=s.setup_id, + name=s.name, + documentation=s.documentation, + status=s.status, + visibility=s.visibility, + organization_id=s.organization_id, + module_id=s.module_id, + module_name=s.module_name, + module_type=s.module_type, + setup_version_id=s.setup_version_id, + setup_version=s.setup_version, + ) + for s in results[offset : offset + limit] + ] diff --git a/src/digitalkin/services/registry/grpc_registry.py b/src/digitalkin/services/registry/grpc_registry.py index 27e7b31c..058fbe18 100644 --- a/src/digitalkin/services/registry/grpc_registry.py +++ b/src/digitalkin/services/registry/grpc_registry.py @@ -4,6 +4,7 @@ the Service Provider's Registry service. """ +from enum import Enum from typing import Any import grpc @@ -13,9 +14,10 @@ registry_requests_pb2, registry_service_pb2_grpc, ) +from google.protobuf.internal.enum_type_wrapper import EnumTypeWrapper from grpc_health.v1 import health_pb2, health_pb2_grpc -from digitalkin.grpc_servers.exceptions import ServerError +from digitalkin.grpc_servers.exceptions import PermissionDeniedError, ServerError from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin from digitalkin.logger import logger @@ -27,7 +29,9 @@ RegistrySetupStatus, RegistryVisibility, SetupInfo, + SetupSummary, ) +from digitalkin.models.settings.registry import get_registry_settings from digitalkin.services.registry.exceptions import ( RegistryModuleNotFoundError, RegistryServiceError, @@ -130,6 +134,12 @@ def _proto_to_setup_info(descriptor: registry_models_pb2.SetupDescriptor) -> Set owner_id=descriptor.owner_id or None, card_id=descriptor.card_id or None, module_id=descriptor.module_id or None, + module_name=descriptor.module.name or None, + module_type=RegistryModuleType[ + registry_enums_pb2.ModuleType.Name(descriptor.module.module_type).removeprefix("MODULE_TYPE_") + ] + if descriptor.HasField("module") + else None, setup_version_id=descriptor.setup_version_id or None, setup_version=descriptor.setup_version or None, config=dict(descriptor.config) if descriptor.config else None, @@ -146,6 +156,7 @@ async def discover_by_id(self, module_id: str) -> ModuleInfo: Raises: RegistryModuleNotFoundError: If module not found. + PermissionDeniedError: If the caller is not permitted. RegistryServiceError: If gRPC call fails. """ logger.debug("Discovering module by ID: %s", module_id) @@ -156,6 +167,8 @@ async def discover_by_id(self, module_id: str) -> ModuleInfo: "GetModule", registry_requests_pb2.GetModuleRequest(module_id=module_id), ) + except PermissionDeniedError: + raise except ServerError as e: msg = f"Failed to discover module '{module_id}': {e}" logger.error(msg) @@ -168,49 +181,81 @@ async def discover_by_id(self, module_id: str) -> ModuleInfo: logger.debug("Module discovered: module_id=%s at %s:%d", response.id, response.address, response.port) return self._proto_to_module_info(response) + @staticmethod + def _module_summary_to_module_info(summary: registry_models_pb2.ModuleSummary) -> ModuleInfo: + """Convert proto ModuleSummary to ModuleInfo (address/port are never populated). + + Args: + summary: Proto ModuleSummary message. + + Returns: + ModuleInfo with mapped fields. + """ + type_name = registry_enums_pb2.ModuleType.Name(summary.module_type).removeprefix("MODULE_TYPE_") + status_name = registry_enums_pb2.ModuleStatus.Name(summary.status).removeprefix("MODULE_STATUS_") + return ModuleInfo( + module_id=summary.id, + module_type=RegistryModuleType[type_name], + version=summary.version, + module_name=summary.name, + documentation=summary.documentation or None, + status=RegistryModuleStatus[status_name], + ) + async def search( self, name: str | None = None, module_type: str | None = None, - organization_id: str | None = None, + limit: int = 20, + offset: int = 0, ) -> list[ModuleInfo]: - """Search for modules by criteria. + """Search the module catalog (module blueprints; needs a setup to be invocable). Args: - name: Filter by name (partial match via query). - module_type: Filter by type (archetype, tool). - organization_id: Filter by organization. + name: Case-insensitive free text matched against module name AND documentation. + module_type: Filter by type (archetype, tool_module, service). + limit: Max results (1-100). + offset: Pagination offset. Returns: - List of matching modules. + List of matching modules as trimmed ModuleInfo (address/port are never + populated by search — resolve via discover_by_id when wiring communication). Raises: + PermissionDeniedError: If the caller is not permitted. RegistryServiceError: If gRPC call fails. """ - logger.debug("Searching modules: name=%s type=%s org=%s", name, module_type, organization_id) + logger.debug("Searching modules: name=%s type=%s", name, module_type) - async with self.handle_grpc_errors("DiscoverModules", RegistryServiceError): - module_types: list[str] = [] - if module_type: - enum_val = RegistryModuleType[module_type.upper()] - module_types.append(f"MODULE_TYPE_{enum_val.name}") + # Encoded before the error-handler scope: an enum-drift ValueError must reach + # the caller as-is (permanent condition), not wrapped as a retryable service error. + module_types: list[str] = [] + if module_type: + enum_val = RegistryModuleType[module_type.upper()] + module_types.append(self._encode_enum(registry_enums_pb2.ModuleType, "MODULE_TYPE", enum_val)) + async with self.handle_grpc_errors("SearchModules", RegistryServiceError): try: response = await self.exec_grpc_query( - "DiscoverModules", - registry_requests_pb2.DiscoverModulesRequest( + "SearchModules", + registry_requests_pb2.SearchModulesRequest( query=name or "", - organization_id=organization_id or "", module_types=module_types, + limit=limit, + offset=offset, ), + # TODO(validate): tightened agent-facing search deadline (was global 30s) + timeout=get_registry_settings().search_timeout_s, ) + except PermissionDeniedError: + raise except ServerError as e: msg = f"Failed to search modules: {e}" logger.error(msg) raise RegistryServiceError(msg) from e - logger.debug("Search returned %d modules", len(response.modules)) - return [self._proto_to_module_info(m) for m in response.modules] + logger.debug("Search returned %d of %d modules", len(response.modules), response.total) + return [self._module_summary_to_module_info(m) for m in response.modules] async def get_status(self, module_id: str) -> ModuleStatusInfo: """Get module status by fetching the module. @@ -223,6 +268,7 @@ async def get_status(self, module_id: str) -> ModuleStatusInfo: Raises: RegistryModuleNotFoundError: If module not found. + PermissionDeniedError: If the caller is not permitted. RegistryServiceError: If gRPC call fails. """ logger.debug("Getting module status: %s", module_id) @@ -233,6 +279,8 @@ async def get_status(self, module_id: str) -> ModuleStatusInfo: "GetModule", registry_requests_pb2.GetModuleRequest(module_id=module_id), ) + except PermissionDeniedError: + raise except ServerError as e: msg = f"Failed to get module status for '{module_id}': {e}" logger.error(msg) @@ -255,30 +303,36 @@ async def register( address: str, port: int, version: str, + module_type: RegistryModuleType = RegistryModuleType.UNSPECIFIED, + documentation: str = "", ) -> ModuleInfo | None: """Register a module with the registry. - Note: The new proto only updates address/port/version for an existing module. - The module must already exist in the registry database. + Note: The module must already exist in the registry database; registration + updates its address/port/version and declares its type. Args: module_id: Unique module identifier. address: Network address. port: Network port. version: Module version. + module_type: Declared module type (tool or archetype/kin). + documentation: Internal documentation for registry index search. Returns: ModuleInfo if successful, None if module not found. Raises: + PermissionDeniedError: If the caller is not permitted. RegistryServiceError: If gRPC call fails. """ logger.info( - "Registering module with registry: module_id=%s at %s:%d version=%s", + "Registering module with registry: module_id=%s at %s:%d version=%s type=%s", module_id, address, port, version, + module_type.value, ) async with self.handle_grpc_errors("RegisterModule", RegistryServiceError): @@ -290,8 +344,12 @@ async def register( address=address, port=port, version=version, + module_type=self._encode_enum(registry_enums_pb2.ModuleType, "MODULE_TYPE", module_type), + documentation=documentation, ), ) + except PermissionDeniedError: + raise except ServerError as e: msg = f"Failed to register module '{module_id}': {e}" logger.error(msg) @@ -319,6 +377,7 @@ async def heartbeat(self, module_id: str) -> RegistryModuleStatus: Current module status after heartbeat. Raises: + PermissionDeniedError: If the caller is not permitted. RegistryServiceError: If gRPC call fails. """ logger.debug("Sending heartbeat: %s", module_id) @@ -329,6 +388,8 @@ async def heartbeat(self, module_id: str) -> RegistryModuleStatus: "Heartbeat", registry_requests_pb2.HeartbeatRequest(module_id=module_id), ) + except PermissionDeniedError: + raise except ServerError as e: msg = f"Failed to send heartbeat for '{module_id}': {e}" logger.error(msg) @@ -348,6 +409,7 @@ async def get_setup(self, setup_id: str) -> SetupInfo | None: SetupInfo if successful, None otherwise. Raises: + PermissionDeniedError: If the caller is not permitted. RegistryServiceError: If gRPC call fails. """ logger.debug("Getting setup", extra={"setup_id": setup_id}) @@ -357,12 +419,134 @@ async def get_setup(self, setup_id: str) -> SetupInfo | None: "GetSetup", registry_requests_pb2.GetSetupRequest(setup_id=setup_id), ) + except PermissionDeniedError: + raise except ServerError as e: msg = f"Failed to get setup '{setup_id}': {e}" logger.error(msg) raise RegistryServiceError(msg) from e return self._proto_to_setup_info(response) + @staticmethod + def _encode_enum(proto_enum: EnumTypeWrapper, prefix: str, member: Enum) -> str: + """Encode a Python registry enum to its proto name, validated against the proto. + + Args: + proto_enum: The proto ``EnumTypeWrapper`` (e.g. ``registry_enums_pb2.SetupStatus``). + prefix: The proto name prefix (e.g. ``"SETUP_STATUS"``). + member: The Python enum member to encode. + + Returns: + The validated proto enum name. + + Raises: + ValueError: If ``member`` has no matching proto member (Python/proto drift). + """ + name = f"{prefix}_{member.name}" + try: + proto_enum.Value(name) # fail closed: never send a filter the server would ignore + except ValueError: + # TODO(validate): remove marker once enum encoding is validated in prod + logger.error("[VALIDATE ENUMENC] no proto member %s — registry filter would silently drop", name) + raise + return name + + @staticmethod + def _summary_to_setup_summary(summary: registry_models_pb2.SetupSummary) -> SetupSummary: + """Convert proto SetupSummary to the search-safe SetupSummary (never carries config). + + Args: + summary: Proto SetupSummary message. + + Returns: + SetupSummary with mapped fields. + """ + status_name = registry_enums_pb2.SetupStatus.Name(summary.status).removeprefix("SETUP_STATUS_") + visibility_name = registry_enums_pb2.Visibility.Name(summary.visibility).removeprefix("VISIBILITY_") + type_name = registry_enums_pb2.ModuleType.Name(summary.module_type).removeprefix("MODULE_TYPE_") + return SetupSummary( + setup_id=summary.id, + name=summary.name, + documentation=summary.documentation or None, + status=RegistrySetupStatus[status_name], + visibility=RegistryVisibility[visibility_name], + organization_id=summary.organization_id or None, + module_id=summary.module_id or None, + module_name=summary.module_name or None, + module_type=RegistryModuleType[type_name], + setup_version_id=summary.setup_version_id or None, + setup_version=summary.setup_version or None, + ) + + async def search_setups( + self, + query: str | None = None, + setup_ids: list[str] | None = None, + module_ids: list[str] | None = None, + module_types: list[RegistryModuleType] | None = None, + statuses: list[RegistrySetupStatus] | None = None, + visibilities: list[RegistryVisibility] | None = None, + limit: int = 20, + offset: int = 0, + ) -> list[SetupSummary]: + """Search the setup catalog (configured, invocable module instances). + + Args: + query: Case-insensitive free text matched against setup name AND documentation. + setup_ids: Restrict to these setup ids. + module_ids: Restrict to setups backed by these modules. + module_types: Filter by backing module type (tool_module, archetype, service). + statuses: Filter by setup status. None = no filter; agent-facing callers + should pass READY/CONFIGURATION_SUCCEEDED for invocable setups. + visibilities: Filter by visibility. + limit: Max results (1-100). + offset: Pagination offset. + + Returns: + Matching setups as ``SetupSummary`` (no ``config`` field by construction). + + Raises: + PermissionDeniedError: If the caller is not permitted. + RegistryServiceError: If gRPC call fails. + """ + logger.debug("Searching setups: query=%s limit=%d offset=%d", query, limit, offset) + + # Encoded before the error-handler scope: an enum-drift ValueError must reach + # the caller as-is (permanent condition), not wrapped as a retryable service error. + encoded_types = [self._encode_enum(registry_enums_pb2.ModuleType, "MODULE_TYPE", t) for t in module_types or []] + encoded_statuses = [ + self._encode_enum(registry_enums_pb2.SetupStatus, "SETUP_STATUS", s) for s in statuses or [] + ] + encoded_visibilities = [ + self._encode_enum(registry_enums_pb2.Visibility, "VISIBILITY", v) for v in visibilities or [] + ] + + async with self.handle_grpc_errors("SearchSetups", RegistryServiceError): + try: + response = await self.exec_grpc_query( + "SearchSetups", + registry_requests_pb2.SearchSetupsRequest( + query=query or "", + setup_ids=setup_ids or [], + module_ids=module_ids or [], + module_types=encoded_types, + statuses=encoded_statuses, + visibilities=encoded_visibilities, + limit=limit, + offset=offset, + ), + # TODO(validate): tightened agent-facing search deadline (was global 30s) + timeout=get_registry_settings().search_timeout_s, + ) + except PermissionDeniedError: + raise + except ServerError as e: + msg = f"Failed to search setups: {e}" + logger.error(msg) + raise RegistryServiceError(msg) from e + + return [self._summary_to_setup_summary(s) for s in response.setups] + async def deregister( # noqa: PLR6301 self, module_id: str ) -> bool: # Protocol uses heartbeat expiration; self available for future override diff --git a/src/digitalkin/services/registry/registry_strategy.py b/src/digitalkin/services/registry/registry_strategy.py index 4be7cbee..fbb8b05d 100644 --- a/src/digitalkin/services/registry/registry_strategy.py +++ b/src/digitalkin/services/registry/registry_strategy.py @@ -6,7 +6,11 @@ from digitalkin.models.services.registry import ( ModuleInfo, RegistryModuleStatus, + RegistryModuleType, + RegistrySetupStatus, + RegistryVisibility, SetupInfo, + SetupSummary, ) from digitalkin.services.base_strategy import BaseStrategy from digitalkin.services.registry.registry_models import ModuleStatusInfo @@ -40,17 +44,119 @@ async def search( self, name: str | None = None, module_type: str | None = None, - organization_id: str | None = None, + limit: int = 20, + offset: int = 0, ) -> list[ModuleInfo]: - """Search for modules by criteria. + """Search the module catalog (module blueprints; needs a setup to be invocable). Args: - name: Filter by name (partial match via query). - module_type: Filter by type (archetype, tool). - organization_id: Filter by organization. + name: Case-insensitive free text matched against module name AND documentation. + module_type: Filter by type (archetype, tool_module, service). + limit: Max results (1-100). + offset: Pagination offset. Returns: - List of matching modules. + List of matching modules as trimmed ModuleInfo (address/port are never + populated by search — resolve via discover_by_id when wiring communication). + """ + ... + + async def search_tools( + self, + name: str | None = None, + limit: int = 20, + offset: int = 0, + ) -> list[ModuleInfo]: + """Tool registry view: modules of type TOOL_MODULE. + + Args: + name: Case-insensitive free text matched against module name AND documentation. + limit: Max results (1-100). + offset: Pagination offset. + + Returns: + List of matching tool modules. + """ + return await self.search( + name=name, + module_type=RegistryModuleType.TOOL_MODULE.value, + limit=limit, + offset=offset, + ) + + async def search_kins( + self, + name: str | None = None, + limit: int = 20, + offset: int = 0, + ) -> list[ModuleInfo]: + """Kin registry view: modules of type ARCHETYPE. + + Args: + name: Case-insensitive free text matched against module name AND documentation. + limit: Max results (1-100). + offset: Pagination offset. + + Returns: + List of matching archetype (kin) modules. + """ + return await self.search( + name=name, + module_type=RegistryModuleType.ARCHETYPE.value, + limit=limit, + offset=offset, + ) + + async def search_services( + self, + name: str | None = None, + limit: int = 20, + offset: int = 0, + ) -> list[ModuleInfo]: + """Service registry view: modules of type SERVICE. + + Args: + name: Case-insensitive free text matched against module name AND documentation. + limit: Max results (1-100). + offset: Pagination offset. + + Returns: + List of matching service modules. + """ + return await self.search( + name=name, + module_type=RegistryModuleType.SERVICE.value, + limit=limit, + offset=offset, + ) + + @abstractmethod + async def search_setups( + self, + query: str | None = None, + setup_ids: list[str] | None = None, + module_ids: list[str] | None = None, + module_types: list[RegistryModuleType] | None = None, + statuses: list[RegistrySetupStatus] | None = None, + visibilities: list[RegistryVisibility] | None = None, + limit: int = 20, + offset: int = 0, + ) -> list[SetupSummary]: + """Search the setup catalog (configured, invocable module instances). + + Args: + query: Case-insensitive free text matched against setup name AND documentation. + setup_ids: Restrict to these setup ids. + module_ids: Restrict to setups backed by these modules. + module_types: Filter by backing module type (tool_module, archetype, service). + statuses: Filter by setup status. None = no filter; agent-facing callers + should pass READY/CONFIGURATION_SUCCEEDED for invocable setups. + visibilities: Filter by visibility. + limit: Max results (1-100). + offset: Pagination offset. + + Returns: + Matching setups as ``SetupSummary`` (no ``config`` field by construction). """ ... @@ -66,17 +172,21 @@ async def register( address: str, port: int, version: str, + module_type: RegistryModuleType = RegistryModuleType.UNSPECIFIED, + documentation: str = "", ) -> ModuleInfo | None: """Register a module with the registry. - Note: The new proto only updates address/port/version for an existing module. - The module must already exist in the registry database. + Note: The module must already exist in the registry database; registration + updates its address/port/version and declares its type. Args: module_id: Unique module identifier. address: Network address. port: Network port. version: Module version. + module_type: Declared module type (tool or archetype/kin). + documentation: Internal documentation for registry index search. Returns: ModuleInfo if successful, None otherwise. @@ -103,6 +213,23 @@ async def get_setup(self, setup_id: str) -> SetupInfo | None: """Get setup info.""" ... + async def get_service_setup(self, setup_id: str) -> dict[str, Any] | None: + """Fetch a service setup's setup_version content JSON. + + The id comes from chat-driven discovery (``search_setups`` + user acceptance), + not from configuration. Goes through ``get_setup`` on every call — the registry + stays the permission gate; nothing cached. Content always reflects the latest + setup version. + + Args: + setup_id: The discovered service setup id. + + Returns: + The setup_version content, or None when the setup is missing or has no content. + """ + setup = await self.get_setup(setup_id) + return setup.config if setup else None + async def wait_for_ready(self, timeout: float = 1.0) -> bool: # noqa: PLR6301 """Check if the registry backend is reachable. diff --git a/src/digitalkin/services/setup/default_setup.py b/src/digitalkin/services/setup/default_setup.py index fa45c3ba..1cda55ba 100644 --- a/src/digitalkin/services/setup/default_setup.py +++ b/src/digitalkin/services/setup/default_setup.py @@ -1,5 +1,6 @@ -"""This module contains the abstract base class for setup strategies.""" +"""In-memory setup strategy mirroring the SetupService protocol.""" +import datetime import secrets import string from typing import Any @@ -12,224 +13,156 @@ class DefaultSetup(SetupStrategy): - """Abstract base class for setup strategies.""" + """In-memory implementation of the setup strategy (same contract as GrpcSetup).""" setups: dict[str, SetupData] - setup_versions: dict[str, dict[str, SetupVersionData]] def __init__(self) -> None: """Initialize the default setup strategy.""" super().__init__() self.setups = {} - self.setup_versions = {} - async def create_setup(self, setup_dict: dict[str, Any]) -> str: - """Create a new setup with comprehensive validation. - - Args: - setup_dict: Dictionary containing setup details. + @staticmethod + def _new_id() -> str: + """Generate a random identifier. Returns: - bool: Success status of setup creation. - - Raises: - ValidationError: If setup data is invalid. - GrpcOperationError: If gRPC operation fails. + A 16-char alphanumeric id. """ - try: - valid_data = SetupData.model_validate(setup_dict["data"]) # Revalidates instance - except ValidationError: - logger.exception("Validation failed for model SetupData") - return "" - - setup_id = setup_dict.get( - "setup_id", "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) - ) - valid_data.id = setup_id - self.setups[setup_id] = valid_data - logger.debug("CREATE SETUP DATA %s:%s successful", setup_id, valid_data) - return setup_id + return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) - async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: - """Retrieve a setup by its unique identifier. + def _get_or_raise(self, setup_id: str) -> SetupData: + """Fetch a stored setup or raise. Args: - setup_dict: Dictionary with 'name' and optional 'version'. + setup_id: The setup identifier. Returns: - Dict[str, Any]: Setup details including optional setup version. + The stored setup. Raises: SetupServiceError: setup_id does not exist. """ - logger.debug("GET setup_id = %s", setup_dict["setup_id"]) - if setup_dict["setup_id"] not in self.setups: - msg = f"GET setup_id = {setup_dict['setup_id']}: setup_id DOESN'T EXIST" + setup = self.setups.get(setup_id) + if setup is None: + msg = f"setup_id = {setup_id}: DOESN'T EXIST" logger.error(msg) raise SetupServiceError(msg) - return self.setups[setup_dict["setup_id"]] + return setup - async def update_setup(self, setup_dict: dict[str, Any]) -> bool: - """Update an existing setup. + async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: + """Retrieve a setup by its unique identifier. Args: - setup_dict: Dictionary with setup update details. + setup_dict: Dictionary with 'setup_id' and optional 'version'. Returns: - bool: Success status of the update operation. + The setup with its current version populated. Raises: - ValidationError: setup object failed validation. - """ - if setup_dict["setup_id"] not in self.setups: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_dict["setup_id"]) - return False - - try: - valid_data = SetupData.model_validate(setup_dict["data"]) # Revalidates instance - except ValidationError: - logger.exception("Validation failed for model SetupData") - return False - - self.setups[setup_dict["update_id"]] = valid_data - return True - - async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: - """Delete a setup by its unique identifier. - - Args: - setup_dict: Dictionary with the setup 'name'. - - Returns: - bool: Success status of deletion. + SetupServiceError: setup_id does not exist. """ - if setup_dict["setup_id"] not in self.setups: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_dict["setup_id"]) - return False - del self.setups[setup_dict["setup_id"]] - return True + return self._get_or_raise(setup_dict.get("setup_id", "")) - async def create_setup_version(self, setup_version_dict: dict[str, Any]) -> str: - """Create a new setup version. + async def create_setup(self, setup_dict: dict[str, Any]) -> SetupData: + """Create a new setup; identifiers are generated locally. Args: - setup_version_dict: Dictionary with setup version details. + setup_dict: Dictionary with 'name' and 'content'. Returns: - str: version of setup version creation. + The created setup with its initial version. Raises: - SetupServiceError: setup object failed validation. + ValueError: If name or content is invalid. """ + setup_id = self._new_id() try: - valid_data = SetupVersionData.model_validate(setup_version_dict["data"]) # Revalidates instance + setup = SetupData( + id=setup_id, + name=setup_dict.get("name", ""), + organisation_id="local", + owner_id="local", + module_id="local", + status="READY", + visibility="VISIBILITY_PRIVATE", + current_setup_version=SetupVersionData( + id=self._new_id(), + setup_id=setup_id, + version="1.0.0", + content=setup_dict.get("content") or {}, + creation_date=datetime.datetime.now(datetime.timezone.utc), + ), + ) except ValidationError as e: - msg = "Validation failed for model SetupVersionData" - logger.exception(msg) - raise SetupServiceError(msg) from e - - if setup_version_dict["setup_id"] not in self.setup_versions: - self.setup_versions[setup_version_dict["setup_id"]] = {} - self.setup_versions[setup_version_dict["setup_id"]][valid_data.version] = valid_data - logger.debug("CREATE SETUP VERSION DATA %s:%s successful", setup_version_dict["setup_id"], valid_data) - return valid_data.version - - async def get_setup_version(self, setup_version_dict: dict[str, Any]) -> SetupVersionData: - """Retrieve a setup version by its unique identifier. - - Args: - setup_version_dict: Dictionary with the setup version 'name'. - - Returns: - Dict[str, Any]: Setup version details. - - Raises: - SetupServiceError: setup_id does not exist. - """ - logger.debug("GET setup_id = %s: version = %s", setup_version_dict["setup_id"], setup_version_dict["version"]) - if setup_version_dict["setup_id"] not in self.setup_versions: - msg = f"GET setup_id = {setup_version_dict['setup_id']}: setup_id DOESN'T EXIST" - logger.error(msg) - raise SetupServiceError(msg) - - return self.setup_versions[setup_version_dict["setup_id"]][setup_version_dict["version"]] + msg = f"Validation failed for SetupData: {e}" + logger.exception("Validation failed for model SetupData") + raise ValueError(msg) from e + if not setup.name: + msg = "name is required" + raise ValueError(msg) + self.setups[setup_id] = setup + logger.debug("CREATE SETUP DATA %s:%s successful", setup_id, setup) + return setup - async def search_setup_versions(self, setup_version_dict: dict[str, Any]) -> list[SetupVersionData]: - """Search for setup versions based on filters. + async def update_setup(self, setup_dict: dict[str, Any]) -> SetupData: + """Update a setup's name and current version content. Args: - setup_version_dict: Dictionary with optional 'name' or 'query_versions' filters. + setup_dict: Dictionary with 'setup_id', 'name' and 'content'. Returns: - List[SetupVersionData]: A list of matching setup version details. + The updated setup with its current version. Raises: SetupServiceError: setup_id does not exist. + ValueError: If the update payload is invalid. """ - if setup_version_dict["setup_id"] not in self.setup_versions: - msg = f"GET setup_id = {setup_version_dict['setup_id']}: setup_id DOESN'T EXIST" - logger.error(msg) - raise SetupServiceError(msg) + setup = self._get_or_raise(setup_dict.get("setup_id", "")) + name = setup_dict.get("name", "") + content = setup_dict.get("content") + if not name or not isinstance(content, dict): + msg = "setup_id, name and content (object) are required" + raise ValueError(msg) + setup.name = name + setup.current_setup_version.content = content + setup.current_setup_version.creation_date = datetime.datetime.now(datetime.timezone.utc) + return setup - return [ - value - for value in self.setup_versions[setup_version_dict["setup_id"]].values() - if setup_version_dict["query_versions"] in value.version - ] - - async def update_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Update an existing setup version. + async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: + """Delete a setup by its unique identifier. Args: - setup_version_dict: Dictionary with setup version update details. + setup_dict: Dictionary with the 'setup_id'. Returns: - bool: Success status of the update operation. + bool: Success status of deletion. """ - if setup_version_dict["setup_id"] not in self.setup_versions: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_version_dict["setup_id"]) + setup_id = setup_dict.get("setup_id", "") + if setup_id not in self.setups: + logger.debug("DELETE setup_id = %s: DOESN'T EXIST", setup_id) return False - - if setup_version_dict["version"] not in self.setup_versions[setup_version_dict["setup_id"]]: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_version_dict["setup_id"]) - return False - - try: - valid_data = SetupVersionData.model_validate(setup_version_dict["data"]) - except ValidationError: - logger.exception("Validation failed for model SetupVersionData") - return False - - self.setup_versions[setup_version_dict["setup_id"]][setup_version_dict["version"]] = valid_data + del self.setups[setup_id] return True - async def delete_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Delete a setup version by its unique identifier. + async def change_visibility(self, setup_dict: dict[str, Any]) -> SetupData: + """Change a setup's visibility scope. Args: - setup_version_dict: Dictionary with the setup version 'name'. + setup_dict: Dictionary with 'setup_id' and 'visibility' + (``public`` | ``private`` | ``internal``). Returns: - bool: Success status of version deletion. - """ - if setup_version_dict["setup_id"] not in self.setup_versions: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_version_dict["setup_id"]) - return False + The setup with its updated visibility. - del self.setup_versions[setup_version_dict["setup_id"]][setup_version_dict["version"]] - return True - - async def list_setups(self, list_dict: dict[str, Any]) -> dict[str, Any]: - """List setups with optional filtering and pagination. - - Args: - list_dict: Dictionary with optional filters. - - Returns: - dict[str, Any]: Dictionary with 'setups' list and 'total_count'. + Raises: + SetupServiceError: setup_id does not exist. + ValueError: If visibility is not a valid scope. """ - setups = list(self.setups.values()) - offset = list_dict.get("offset", 0) - limit = list_dict.get("limit", 0) - setups = setups[offset : offset + limit] if limit > 0 else setups[offset:] - return {"setups": [s.model_dump() for s in setups], "total_count": len(self.setups)} + setup = self._get_or_raise(setup_dict.get("setup_id", "")) + scope = str(setup_dict.get("visibility", "")).lower() + if scope not in {"public", "private", "internal"}: + msg = f"invalid visibility '{setup_dict.get('visibility')}'; use 'public', 'private' or 'internal'" + raise ValueError(msg) + setup.visibility = f"VISIBILITY_{scope.upper()}" + return setup diff --git a/src/digitalkin/services/setup/grpc_setup.py b/src/digitalkin/services/setup/grpc_setup.py index 5df21d87..d5a249c4 100644 --- a/src/digitalkin/services/setup/grpc_setup.py +++ b/src/digitalkin/services/setup/grpc_setup.py @@ -17,7 +17,7 @@ from digitalkin.logger import logger from digitalkin.models.grpc_servers.models import ClientConfig from digitalkin.services.setup.exceptions import SetupServiceError -from digitalkin.services.setup.setup_strategy import SetupData, SetupStrategy, SetupVersionData +from digitalkin.services.setup.setup_strategy import SetupData, SetupStrategy from digitalkin.utils.proto_utils import ProtoUtils @@ -25,7 +25,8 @@ class GrpcSetup(SetupStrategy, GrpcClientWrapper): """gRPC client implementation for the Setup service. Communicates with the remote SetupService gRPC server to manage - setup configurations and versions. + setup configurations. Owner/organisation/module of a created setup + are resolved server-side from the request context metadata. """ service_name: str = "SetupService" @@ -50,14 +51,14 @@ async def handle_grpc_errors( # noqa: PLR6301 """Context manager for consistent gRPC error handling with detailed logging. Args: - operation: Description of the operation being performed (e.g., "Get Setup", "Create Setup Version"). + operation: Description of the operation being performed (e.g., "Get Setup", "Change Visibility"). Yields: Allow error handling in context. Raises: PermissionDeniedError: Service rejected the call with PERMISSION_DENIED. - ValueError: Pydantic model validation failed - input data is malformed. + ValueError: Pydantic model validation failed - response data is malformed. ServerError: gRPC communication failed - remote service returned error or is unreachable. SetupServiceError: Unexpected error during setup operation - includes connection/timeout issues. """ @@ -65,6 +66,9 @@ async def handle_grpc_errors( # noqa: PLR6301 yield except PermissionDeniedError: raise + except ServerError: + # Already normalised by exec_grpc_query (status code + details) — pass through. + raise except ValidationError as e: msg = f"Validation failed for {operation}: {e}" logger.error( @@ -106,276 +110,175 @@ async def handle_grpc_errors( # noqa: PLR6301 ) raise SetupServiceError(msg) from e - async def create_setup(self, setup_dict: dict[str, Any]) -> str: - """Create a new setup with comprehensive validation. + @staticmethod + def _to_setup_data(setup_msg: setup_pb2.Setup, version_msg: setup_pb2.SetupVersion) -> SetupData: + """Assemble a ``SetupData`` from a response's setup + sibling setup_version. + + The setup's embedded ``current_setup_version`` wins when populated; + otherwise the response-level ``setup_version`` fills it. Args: - setup_dict: Dictionary containing setup details. + setup_msg: The response ``Setup`` message. + version_msg: The response-level ``SetupVersion`` message. Returns: - bool: Success status of setup creation. + The validated ``SetupData``. Raises: - ValidationError: If setup data is invalid. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. + SetupServiceError: If neither carries a setup version. """ - async with self.handle_grpc_errors("Setup Creation"): - valid_data = SetupData.model_validate(setup_dict) - - request = setup_pb2.CreateSetupRequest( - name=valid_data.name, - organisation_id=valid_data.organisation_id, - owner_id=valid_data.owner_id, - module_id=valid_data.module_id, - current_setup_version=setup_pb2.SetupVersion(**valid_data.current_setup_version.model_dump()), - ) - response = await self.exec_grpc_query("CreateSetup", request) - logger.debug("Setup '%s' query sent successfully", valid_data.name) - return response + if setup_msg.HasField("current_setup_version"): + version_msg = setup_msg.current_setup_version + elif not version_msg.id: + msg = f"setup '{setup_msg.id}' returned without a setup version" + raise SetupServiceError(msg) + data = ProtoUtils.proto_to_dict(setup_msg, with_defaults=True) + data["current_setup_version"] = ProtoUtils.proto_to_dict(version_msg, with_defaults=True) + return SetupData(**data) async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: """Retrieve a setup by its unique identifier. Args: - setup_dict: Dictionary with 'name' and optional 'version'. + setup_dict: Dictionary with 'setup_id' and optional 'version'. Returns: - dict[str, Any]: Setup details including optional setup version. + The setup with its current version populated. Raises: - ValidationError: If the setup name is missing. + ValueError: If the setup_id is missing. ServerError: If gRPC operation fails. SetupServiceError: For any unexpected internal error. """ + if not setup_dict.get("setup_id"): + msg = "setup_id is required" + raise ValueError(msg) async with self.handle_grpc_errors("Get Setup"): - if "setup_id" not in setup_dict: - msg = "Setup name is required" - raise ValidationError(msg) - + # Proto3 optional: a None kwarg leaves the field unset (no empty-string presence). request = setup_pb2.GetSetupRequest( setup_id=setup_dict["setup_id"], - version=setup_dict.get("version", ""), + version=setup_dict.get("version") or None, ) response = await self.exec_grpc_query("GetSetup", request) - response_data = ProtoUtils.proto_to_dict(response) - return SetupData(**response_data["setup"]) - - async def update_setup(self, setup_dict: dict[str, Any]) -> bool: - """Update an existing setup. - - Args: - setup_dict: Dictionary with setup update details. - - Returns: - bool: Success status of the update operation. - - Raises: - ValidationError: If setup data is invalid. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - current_setup_version = None - - async with self.handle_grpc_errors("Setup Update"): - valid_data = SetupData.model_validate(setup_dict) - - if valid_data.current_setup_version is not None: - current_setup_version = setup_pb2.SetupVersion(**valid_data.current_setup_version.model_dump()) - - request = setup_pb2.UpdateSetupRequest( - setup_id=valid_data.id, - name=valid_data.name, - owner_id=valid_data.owner_id or "", - current_setup_version=current_setup_version, - ) - response = await self.exec_grpc_query("UpdateSetup", request) - logger.debug("Setup '%s' query sent successfully", valid_data.name) - return response.success - - async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: - """Delete a setup by its unique identifier. - - Args: - setup_dict: Dictionary with the setup 'setup_id'. - - Returns: - bool: Success status of deletion. - - Raises: - ValidationError: If the setup setup_id is missing. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("Setup Deletion"): - setup_id = setup_dict.get("setup_id") - if not setup_id: - msg = "Setup name is required for deletion" - raise ValidationError(msg) - request = setup_pb2.DeleteSetupRequest(setup_id=setup_id) - response = await self.exec_grpc_query("DeleteSetup", request) - logger.debug("Setup '%s' query sent successfully", setup_id) - return response.success + return self._to_setup_data(response.setup, response.setup_version) - async def create_setup_version(self, setup_version_dict: dict[str, Any]) -> str: - """Create a new setup version. + async def create_setup(self, setup_dict: dict[str, Any]) -> SetupData: + """Create a new setup; owner/organisation/module derive from the request context. Args: - setup_version_dict: Dictionary with setup version details. + setup_dict: Dictionary with 'name' and 'content'. Returns: - str: version of setup version creation. + The created setup with its initial version. Raises: - ValidationError: If setup version data is invalid. + ValueError: If name or content is missing. ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. + SetupServiceError: If the server reports failure or an unexpected error occurs. """ - async with self.handle_grpc_errors("Setup Version Creation"): - valid_data = SetupVersionData.model_validate(setup_version_dict) + if not setup_dict.get("name") or not isinstance(setup_dict.get("content"), dict): + msg = "name and content (object) are required" + raise ValueError(msg) + async with self.handle_grpc_errors("Setup Creation"): content_struct = Struct() - content_struct.update(valid_data.content) - request = setup_pb2.CreateSetupVersionRequest( - setup_id=valid_data.setup_id, - version=valid_data.version, - content=content_struct, - ) - logger.debug( - "Setup Version '%s' for setup '%s' query sent successfully", - valid_data.version, - valid_data.setup_id, - ) - return await self.exec_grpc_query("CreateSetupVersion", request) - - async def get_setup_version(self, setup_version_dict: dict[str, Any]) -> SetupVersionData: - """Retrieve a setup version by its unique identifier. - - Args: - setup_version_dict: Dictionary with the setup version 'setup_version_id'. - - Returns: - dict[str, Any]: Setup version details. - - Raises: - ValidationError: If the setup version id is missing. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("Get Setup Version"): - setup_version_id = setup_version_dict.get("setup_version_id") - if not setup_version_id: - msg = "Setup version id is required" - raise ValidationError(msg) - request = setup_pb2.GetSetupVersionRequest(setup_version_id=setup_version_id) - response = await self.exec_grpc_query("GetSetupVersion", request) - return SetupVersionData(**ProtoUtils.proto_to_dict(response.setup_version)) - - async def search_setup_versions(self, setup_version_dict: dict[str, Any]) -> list[SetupVersionData]: - """Search for setup versions based on filters. - - Args: - setup_version_dict: Dictionary with optional 'name' and 'version' filters. - - Returns: - list[dict[str, Any]]: A list of matching setup version details. - - Raises: - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - ValidationError: If both name and version are not provided. - """ - async with self.handle_grpc_errors("Search Setup Versions"): - if "name" not in setup_version_dict and "version" not in setup_version_dict: - msg = "Either name or version must be provided" - raise ValidationError(msg) - request = setup_pb2.SearchSetupVersionsRequest( - setup_id=setup_version_dict.get("setup_id", ""), - version=setup_version_dict.get("version", ""), - ) - response = await self.exec_grpc_query("SearchSetupVersions", request) - return [SetupVersionData(**ProtoUtils.proto_to_dict(sv)) for sv in response.setup_versions] + content_struct.update(setup_dict["content"]) + request = setup_pb2.CreateSetupRequest(name=setup_dict["name"], content=content_struct) + response = await self.exec_grpc_query("CreateSetup", request) + if not response.success: + msg = f"setup creation refused for '{setup_dict['name']}'" + raise SetupServiceError(msg) + logger.debug("Setup '%s' created successfully", setup_dict["name"]) + return self._to_setup_data(response.setup, response.setup_version) - async def update_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Update an existing setup version. + async def update_setup(self, setup_dict: dict[str, Any]) -> SetupData: + """Update a setup's name and current version content. Args: - setup_version_dict: Dictionary with setup version update details. + setup_dict: Dictionary with 'setup_id', 'name' and 'content'. Returns: - bool: Success status of the update operation. + The updated setup with its current version. Raises: - ValidationError: If setup version data is invalid. + ValueError: If setup_id, name or content is missing. ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. + SetupServiceError: If the server reports failure or an unexpected error occurs. """ - async with self.handle_grpc_errors("Setup Version Update"): - valid_data = SetupVersionData.model_validate(setup_version_dict) + if ( + not setup_dict.get("setup_id") + or not setup_dict.get("name") + or not isinstance(setup_dict.get("content"), dict) + ): + msg = "setup_id, name and content (object) are required" + raise ValueError(msg) + async with self.handle_grpc_errors("Setup Update"): content_struct = Struct() - content_struct.update(valid_data.content) - request = setup_pb2.UpdateSetupVersionRequest( - setup_version_id=valid_data.id, - version=valid_data.version, + content_struct.update(setup_dict["content"]) + request = setup_pb2.UpdateSetupRequest( + setup_id=setup_dict["setup_id"], + name=setup_dict["name"], content=content_struct, ) - response = await self.exec_grpc_query("UpdateSetupVersion", request) - logger.debug( - "Setup Version '%s' for setup '%s' query sent successfully", - valid_data.id, - valid_data.setup_id, - ) - return response.success + response = await self.exec_grpc_query("UpdateSetup", request) + if not response.success: + msg = f"setup update refused for '{setup_dict['setup_id']}'" + raise SetupServiceError(msg) + logger.debug("Setup '%s' updated successfully", setup_dict["setup_id"]) + return self._to_setup_data(response.setup, response.setup_version) - async def delete_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Delete a setup version by its unique identifier. + async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: + """Delete a setup by its unique identifier. Args: - setup_version_dict: Dictionary with the setup version 'name'. + setup_dict: Dictionary with the 'setup_id'. Returns: - bool: Success status of version deletion. + bool: Success status of deletion. Raises: - ValidationError: If the setup version name is missing. + ValueError: If the setup_id is missing. ServerError: If gRPC operation fails. SetupServiceError: For any unexpected internal error. """ - async with self.handle_grpc_errors("Setup Version Deletion"): - setup_version_id = setup_version_dict.get("setup_version_id") - if not setup_version_id: - msg = "Setup version id is required for deletion" - raise ValidationError(msg) - request = setup_pb2.DeleteSetupVersionRequest(setup_version_id=setup_version_id) - response = await self.exec_grpc_query("DeleteSetupVersion", request) - logger.debug("Setup Version '%s' query sent successfully", setup_version_id) + setup_id = setup_dict.get("setup_id") + if not setup_id: + msg = "setup_id is required for deletion" + raise ValueError(msg) + async with self.handle_grpc_errors("Setup Deletion"): + request = setup_pb2.DeleteSetupRequest(setup_id=setup_id) + response = await self.exec_grpc_query("DeleteSetup", request) + logger.debug("Setup '%s' deletion query sent successfully", setup_id) return response.success - async def list_setups(self, list_dict: dict[str, Any]) -> dict[str, Any]: - """List setups with optional filtering and pagination. + async def change_visibility(self, setup_dict: dict[str, Any]) -> SetupData: + """Change a setup's visibility scope. Args: - list_dict: Dictionary with optional filters: - - organisation_id: Filter by organisation - - owner_id: Filter by owner - - limit: Maximum number of results - - offset: Number of results to skip + setup_dict: Dictionary with 'setup_id' and 'visibility' + (``public`` | ``private`` | ``internal``). Returns: - dict[str, Any]: Dictionary with 'setups' list and 'total_count'. + The setup with its updated visibility. Raises: + ValueError: If setup_id is missing or visibility is not a valid scope. ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. + SetupServiceError: If the server reports failure or an unexpected error occurs. """ - async with self.handle_grpc_errors("List Setups"): - request = setup_pb2.ListSetupsRequest( - organisation_id=list_dict.get("organisation_id", ""), - owner_id=list_dict.get("owner_id", ""), - limit=list_dict.get("limit", 0), - offset=list_dict.get("offset", 0), - ) - response = await self.exec_grpc_query("ListSetups", request) - return { - "setups": [ProtoUtils.proto_to_dict(setup) for setup in response.setups], - "total_count": response.total_count, - } + setup_id = setup_dict.get("setup_id") + if not setup_id: + msg = "setup_id is required" + raise ValueError(msg) + scope = str(setup_dict.get("visibility", "")).lower() + if scope not in {"public", "private", "internal"}: # fail closed: never send UNSPECIFIED or unknown + msg = f"invalid visibility '{setup_dict.get('visibility')}'; use 'public', 'private' or 'internal'" + raise ValueError(msg) + async with self.handle_grpc_errors("Change Visibility"): + # Proto ctors accept the enum member name; the guard above keeps it fail-closed. + request = setup_pb2.ChangeVisibilityRequest(setup_id=setup_id, visibility=f"VISIBILITY_{scope.upper()}") + response = await self.exec_grpc_query("ChangeVisibility", request) + if not response.success: + msg = f"visibility change refused for '{setup_id}'" + raise SetupServiceError(msg) + logger.debug("Setup '%s' visibility changed to %s", setup_id, scope) + return self._to_setup_data(response.setup, response.setup_version) diff --git a/src/digitalkin/services/setup/setup_strategy.py b/src/digitalkin/services/setup/setup_strategy.py index 9d0f4812..48b7df67 100644 --- a/src/digitalkin/services/setup/setup_strategy.py +++ b/src/digitalkin/services/setup/setup_strategy.py @@ -18,7 +18,11 @@ class SetupVersionData(BaseModel): class SetupData(BaseModel): - """Pydantic model for Setup data validation.""" + """Pydantic model for Setup data validation. + + ``status``/``visibility`` carry the proto enum names (e.g. ``READY``, + ``VISIBILITY_PRIVATE``); empty when the backend predates them. + """ id: str name: str @@ -26,10 +30,17 @@ class SetupData(BaseModel): owner_id: str module_id: str current_setup_version: SetupVersionData + status: str = "" + visibility: str = "" class SetupStrategy(ABC): - """Abstract base class for setup strategies.""" + """Abstract base class for setup strategies. + + Mirrors the SetupService protocol: setup-level CRUD plus visibility change. + The version lifecycle is platform-owned — content flows through the setup's + ``current_setup_version``, never through standalone version RPCs. + """ def __init__(self) -> None: """Initialize the setup strategy.""" @@ -37,120 +48,73 @@ def __init__(self) -> None: def __post_init__(self, *args: Any, **kwargs: Any) -> None: """Lifecycle hook for post-initialization. Subclasses override with specific params.""" - @abstractmethod - async def create_setup(self, setup_dict: dict[str, Any]) -> str: - """Create a new setup with comprehensive validation. - - Args: - setup_dict: Dictionary containing setup details. - - Returns: - bool: Success status of setup creation. - - Raises: - ValidationError: If setup data is invalid. - GrpcOperationError: If gRPC operation fails. - """ - @abstractmethod async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: """Retrieve a setup by its unique identifier. Args: - setup_dict: Dictionary with 'name' and optional 'version'. + setup_dict: Dictionary with 'setup_id' and optional 'version'. Returns: - Dict[str, Any]: Setup details including optional setup version. + The setup with its current version populated. """ - @abstractmethod - async def update_setup(self, setup_dict: dict[str, Any]) -> bool: - """Update an existing setup. - - Args: - setup_dict: Dictionary with setup update details. + async def create_service_setup(self, name: str, content: dict[str, Any]) -> SetupData: + """Create a service setup — a shareable configuration document. - Returns: - bool: Success status of the update operation. - """ - - @abstractmethod - async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: - """Delete a setup by its unique identifier. + Only a name and the content JSON are needed; everything else (owner, + organisation, backing module, kind) is derived server-side. Args: - setup_dict: Dictionary with the setup 'name'. + name: Human-readable service name. + content: The service configuration JSON. Returns: - bool: Success status of deletion. - """ - - @abstractmethod - async def create_setup_version(self, setup_version_dict: dict[str, Any]) -> str: - """Create a new setup version. - - Args: - setup_version_dict: Dictionary with setup version details. - - Returns: - str: name of setup version creation. - """ - - @abstractmethod - async def get_setup_version(self, setup_version_dict: dict[str, Any]) -> SetupVersionData: - """Retrieve a setup version by its unique identifier. - - Args: - setup_version_dict: Dictionary with the setup version 'name'. - - Returns: - Dict[str, Any]: Setup version details. + The created setup with its initial version. """ + return await self.create_setup({"name": name, "content": content}) @abstractmethod - async def search_setup_versions(self, setup_version_dict: dict[str, Any]) -> list[SetupVersionData]: - """Search for setup versions based on filters. + async def create_setup(self, setup_dict: dict[str, Any]) -> SetupData: + """Create a new setup; owner/organisation/module derive from the request context. Args: - setup_version_dict: Dictionary with optional 'name' and 'version' filters. + setup_dict: Dictionary with 'name' and 'content'. Returns: - List[Dict[str, Any]]: A list of matching setup version details. + The created setup with its initial version. """ @abstractmethod - async def update_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Update an existing setup version. + async def update_setup(self, setup_dict: dict[str, Any]) -> SetupData: + """Update a setup's name and current version content. Args: - setup_version_dict: Dictionary with setup version update details. + setup_dict: Dictionary with 'setup_id', 'name' and 'content'. Returns: - bool: Success status of the update operation. + The updated setup with its current version. """ @abstractmethod - async def delete_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Delete a setup version by its unique identifier. + async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: + """Delete a setup by its unique identifier. Args: - setup_version_dict: Dictionary with the setup version 'name'. + setup_dict: Dictionary with the 'setup_id'. Returns: - bool: Success status of version deletion. + bool: Success status of deletion. """ @abstractmethod - async def list_setups(self, list_dict: dict[str, Any]) -> dict[str, Any]: - """List setups with optional filtering and pagination. + async def change_visibility(self, setup_dict: dict[str, Any]) -> SetupData: + """Change a setup's visibility scope. Args: - list_dict: Dictionary with optional filters: - - organisation_id: Filter by organisation - - owner_id: Filter by owner - - limit: Maximum number of results - - offset: Number of results to skip + setup_dict: Dictionary with 'setup_id' and 'visibility' + (``public`` | ``private`` | ``internal``). Returns: - dict[str, Any]: Dictionary with 'setups' list and 'total_count'. + The setup with its updated visibility. """ diff --git a/src/digitalkin/services/storage/default_storage.py b/src/digitalkin/services/storage/default_storage.py index d6fdec09..48fcb727 100644 --- a/src/digitalkin/services/storage/default_storage.py +++ b/src/digitalkin/services/storage/default_storage.py @@ -9,7 +9,7 @@ from pydantic import BaseModel from digitalkin.logger import logger -from digitalkin.models.services.storage import DataType +from digitalkin.models.services.storage import DataType, Visibility from digitalkin.services.storage.storage_strategy import ( StorageRecord, StorageStrategy, @@ -67,6 +67,7 @@ def _load_from_file(self) -> dict[str, StorageRecord]: record_id=rd["record_id"], data=data_model, data_type=DataType[rd["data_type"]], + visibility=Visibility[rd["visibility"]] if rd.get("visibility") else Visibility.UNSPECIFIED, creation_date=datetime.datetime.fromisoformat(rd["creation_date"]) if rd.get("creation_date") else None, @@ -97,6 +98,7 @@ def _save_to_file(self) -> None: "collection": record.collection, "record_id": record.record_id, "data_type": record.data_type.name, + "visibility": record.visibility.name, "data": record.data.model_dump(), "creation_date": record.creation_date.isoformat() if record.creation_date else None, "update_date": record.update_date.isoformat() if record.update_date else None, @@ -141,21 +143,29 @@ async def _read(self, collection: str, record_id: str, context: str) -> StorageR Args: collection: The unique name to retrieve data for record_id: The unique ID of the record - context: Owner context scoping the lookup. + context: Resolved owner context scoping the lookup. Returns: StorageRecord: The corresponding record """ return self.storage.get(self._key(context, collection, record_id)) - async def _update(self, collection: str, record_id: str, data: BaseModel, context: str) -> StorageRecord | None: + async def _update( + self, + collection: str, + record_id: str, + data: BaseModel, + context: str, + visibility: Visibility = Visibility.UNSPECIFIED, + ) -> StorageRecord | None: """Update a record in the database scoped to a specific context. Args: collection: The unique name to retrieve data for record_id: The unique ID of the record data: The data to modify - context: Owner context scoping the update. + context: Resolved owner context scoping the update. + visibility: New read-access scope; UNSPECIFIED leaves it unchanged. Returns: StorageRecord: The modified record @@ -165,6 +175,8 @@ async def _update(self, collection: str, record_id: str, data: BaseModel, contex if not rec: return None rec.data = data + if visibility is not Visibility.UNSPECIFIED: + rec.visibility = visibility rec.update_date = datetime.datetime.now(datetime.timezone.utc) self._save_to_file() logger.debug("Modified %s", key) @@ -176,7 +188,7 @@ async def _remove(self, collection: str, record_id: str, context: str) -> bool: Args: collection: The unique name to retrieve data for record_id: The unique ID of the record - context: Owner context scoping the deletion. + context: Resolved owner context scoping the deletion. Returns: bool: True if the record was removed, False otherwise @@ -189,25 +201,32 @@ async def _remove(self, collection: str, record_id: str, context: str) -> bool: logger.debug("Removed %s", key) return True - async def _list(self, collection: str, context: str) -> list[StorageRecord]: + async def _list( + self, collection: str, context: str, visibilities: list[Visibility] | None = None + ) -> list[StorageRecord]: """List records in a collection scoped to a specific context. Args: collection: The unique name to retrieve data for - context: Owner context scoping the listing. + context: Resolved owner context scoping the listing. + visibilities: Optional read-access scopes to filter by (None = no filter). Returns: A list of storage records """ prefix = f"{context}|{collection}:" - return [r for k, r in self.storage.items() if k.startswith(prefix)] + records = [r for k, r in self.storage.items() if k.startswith(prefix)] + if visibilities: + allowed = set(visibilities) + records = [r for r in records if r.visibility in allowed] + return records async def _remove_collection(self, collection: str, context: str) -> bool: """Wipe a collection scoped to a specific context. Args: collection: The unique name to retrieve data for - context: Owner context scoping the wipe. + context: Resolved owner context scoping the wipe. Returns: bool: True if the collection was removed, False otherwise diff --git a/src/digitalkin/services/storage/grpc_storage.py b/src/digitalkin/services/storage/grpc_storage.py index bbf5f201..29383e05 100644 --- a/src/digitalkin/services/storage/grpc_storage.py +++ b/src/digitalkin/services/storage/grpc_storage.py @@ -1,5 +1,7 @@ """This module implements the default storage strategy.""" +from typing import cast + from agentic_mesh_protocol.storage.v1 import data_pb2, storage_service_pb2_grpc from google.protobuf.struct_pb2 import Struct from pydantic import BaseModel @@ -8,7 +10,7 @@ from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper from digitalkin.logger import logger from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.models.services.storage import DataType +from digitalkin.models.services.storage import DataType, Visibility from digitalkin.services.storage.exceptions import StorageServiceError from digitalkin.services.storage.storage_strategy import ( StorageRecord, @@ -38,6 +40,33 @@ def _is_circuit_open(error: Exception) -> bool: """ return isinstance(error.__cause__, CircuitOpenError) + def _context_enum(self, context: str) -> data_pb2.ContextStorage: + """Map a resolved context string to the wire's context-kind enum. + + Since dev4 the request carries only the kind; the concrete id is resolved + server-side from the request metadata stamped by ``RequestIdClientInterceptor``. + USERS/ORGANIZATIONS are read-only cross-owner scopes — only the kind is sent; + the server derives the owning user/organization from the request context (no id + is transmitted by the client). + + Args: + context: The resolved context string from ``_resolve_context``. + + Returns: + The matching ``CONTEXT_*`` wire enum. + """ + # TODO(validate): remove after prod validation + # [VALIDATE CTXENUM] server resolves the concrete id (incl. setup->current version) from metadata + if context == self.setup_version_id or context.startswith("setup_versions:"): + return data_pb2.CONTEXT_SETUP_VERSIONS + if context.startswith("users:"): + return data_pb2.CONTEXT_USERS + if context.startswith("organizations:"): + return data_pb2.CONTEXT_ORGANIZATIONS + if context.startswith("unspecified:"): + return data_pb2.CONTEXT_UNSPECIFIED + return data_pb2.CONTEXT_MISSIONS + def _build_record_from_proto(self, proto: data_pb2.StorageRecord) -> StorageRecord: """Convert a protobuf StorageRecord message into our Pydantic model. @@ -55,6 +84,7 @@ def _build_record_from_proto(self, proto: data_pb2.StorageRecord) -> StorageReco coll = proto.collection rid = proto.record_id dtype = DataType[data_pb2.DataType.Name(proto.data_type)] + visibility = Visibility(proto.visibility) # Selective deserialization: only the nested Struct payload payload = ProtoUtils.proto_to_dict(proto.data) if proto.HasField("data") else {} @@ -70,10 +100,31 @@ def _build_record_from_proto(self, proto: data_pb2.StorageRecord) -> StorageReco record_id=rid, data=validated, data_type=dtype, + visibility=visibility, creation_date=creation_date, update_date=update_date, ) + def _build_record_or_skip(self, proto: data_pb2.StorageRecord) -> StorageRecord | None: + """Convert a proto record, or log and return None if conversion/validation fails. + + Keeps one foreign-shaped record (e.g. written by another module) from + failing an entire ListRecords result. + + Args: + proto: gRPC StorageRecord + + Returns: + The converted record, or None if it could not be validated. + """ + try: + return self._build_record_from_proto(proto) + except Exception: + logger.warning( + "Skipping invalid record %s:%s in ListRecords", proto.collection, proto.record_id, exc_info=True + ) + return None + async def _store(self, record: StorageRecord) -> StorageRecord: """Create a new record in the database. @@ -88,16 +139,17 @@ async def _store(self, record: StorageRecord) -> StorageRecord: StorageServiceError: If there is an error while storing the record """ logger.debug("debug:_store collection=%s id=%s", record.collection, record.record_id) + data_struct = Struct() + data_struct.update(record.data.model_dump()) + req = data_pb2.StoreRecordRequest( + data=data_struct, + context=self._context_enum(record.context), + collection=record.collection, + record_id=record.record_id, + data_type=record.data_type.name, + visibility=cast("data_pb2.Visibility", record.visibility.value), + ) try: - data_struct = Struct() - data_struct.update(record.data.model_dump()) - req = data_pb2.StoreRecordRequest( - data=data_struct, - context=record.context, - collection=record.collection, - record_id=record.record_id, - data_type=record.data_type.name, - ) resp = await self.exec_grpc_query("StoreRecord", req) return self._build_record_from_proto(resp.stored_data) except PermissionDeniedError: @@ -123,7 +175,7 @@ async def _read(self, collection: str, record_id: str, context: str) -> StorageR logger.debug("debug:_read context=%s collection=%s id=%s", context, collection, record_id) try: req = data_pb2.ReadRecordRequest( - context=context, + context=self._context_enum(context), collection=collection, record_id=record_id, ) @@ -146,6 +198,7 @@ async def _update( record_id: str, data: BaseModel, context: str, + visibility: Visibility = Visibility.UNSPECIFIED, ) -> StorageRecord | None: """Overwrite a document via gRPC scoped to a specific context. @@ -156,15 +209,16 @@ async def _update( PermissionDeniedError: If the service rejects the call with PERMISSION_DENIED. """ logger.debug("debug:_update context=%s collection=%s id=%s", context, collection, record_id) + struct = Struct() + struct.update(data.model_dump()) + req = data_pb2.UpdateRecordRequest( + data=struct, + context=self._context_enum(context), + collection=collection, + record_id=record_id, + visibility=cast("data_pb2.Visibility", visibility.value), + ) try: - struct = Struct() - struct.update(data.model_dump()) - req = data_pb2.UpdateRecordRequest( - data=struct, - context=context, - collection=collection, - record_id=record_id, - ) resp = await self.exec_grpc_query("UpdateRecord", req) return self._build_record_from_proto(resp.stored_data) except PermissionDeniedError: @@ -190,7 +244,7 @@ async def _remove(self, collection: str, record_id: str, context: str) -> bool: logger.debug("debug:_remove context=%s collection=%s id=%s", context, collection, record_id) try: req = data_pb2.RemoveRecordRequest( - context=context, + context=self._context_enum(context), collection=collection, record_id=record_id, ) @@ -207,7 +261,9 @@ async def _remove(self, collection: str, record_id: str, context: str) -> bool: return False return True - async def _list(self, collection: str, context: str) -> list[StorageRecord]: + async def _list( + self, collection: str, context: str, visibilities: list[Visibility] | None = None + ) -> list[StorageRecord]: """List all documents in a collection via gRPC scoped to a specific context. Returns: @@ -219,11 +275,12 @@ async def _list(self, collection: str, context: str) -> list[StorageRecord]: logger.debug("debug:_list context=%s collection=%s", context, collection) try: req = data_pb2.ListRecordsRequest( - context=context, + context=self._context_enum(context), collection=collection, ) + if visibilities: + req.visibilities.extend(cast("data_pb2.Visibility", v.value) for v in visibilities) resp = await self.exec_grpc_query("ListRecords", req) - return [self._build_record_from_proto(r) for r in resp.records] except PermissionDeniedError: # TODO(validate): remove after prod validation logger.warning("[VALIDATE PD1] storage ListRecords permission denied") @@ -235,6 +292,8 @@ async def _list(self, collection: str, context: str) -> list[StorageRecord]: logger.warning("gRPC ListRecords failed for %s: %s", collection, e) return [] + return [record for r in resp.records if (record := self._build_record_or_skip(r)) is not None] + async def _remove_collection(self, collection: str, context: str) -> bool: """Delete an entire collection via gRPC scoped to a specific context. @@ -246,7 +305,7 @@ async def _remove_collection(self, collection: str, context: str) -> bool: """ try: req = data_pb2.RemoveCollectionRequest( - context=context, + context=self._context_enum(context), collection=collection, ) await self.exec_grpc_query("RemoveCollection", req) diff --git a/src/digitalkin/services/storage/storage_strategy.py b/src/digitalkin/services/storage/storage_strategy.py index 1f86fbe0..9723ec72 100644 --- a/src/digitalkin/services/storage/storage_strategy.py +++ b/src/digitalkin/services/storage/storage_strategy.py @@ -8,8 +8,7 @@ from pydantic import BaseModel, Field -from digitalkin.logger import logger -from digitalkin.models.services.storage import DataType +from digitalkin.models.services.storage import ContextStorage, DataType, Visibility from digitalkin.services.base_strategy import BaseStrategy from digitalkin.services.storage.exceptions import StorageServiceError @@ -21,14 +20,15 @@ class StorageRecord(BaseModel): collection: str = Field(..., description="Logical collection name") record_id: str = Field(..., description="Unique ID of this record in its collection") data_type: DataType = Field(default=DataType.OUTPUT, description="Category of the data of this record") + visibility: Visibility = Field( + default=Visibility.UNSPECIFIED, + description="Read-access scope of this record (UNSPECIFIED = storage-service default)", + ) data: BaseModel = Field(..., description="The typed payload of this record") creation_date: datetime.datetime | None = Field(default=None, description="When this record was first created") update_date: datetime.datetime | None = Field(default=None, description="When this record was last modified") -Scope = Literal["mission", "setup"] - - class StorageStrategy(BaseStrategy, ABC): """Define CRUD + list/remove-collection against a collection/record store. @@ -37,14 +37,35 @@ class StorageStrategy(BaseStrategy, ABC): (setup-version scope). Both attributes are expected to already contain the full prefix (`missions:` / `setup_versions:`). - Public methods accept `scope: Literal["mission", "setup"]` (default - `"mission"`); internally we resolve it to the matching context string and + Public methods accept `scope: Literal["mission", "setup", "user", "organization"]` + (default `"mission"`); internally we resolve it to the matching context string and pass that to the abstract `_store/_read/_update/_remove/_list/_remove_collection`. + `user`/`organization` are read-only cross-owner scopes usable only for listing. """ - def _resolve_context(self, scope: Scope) -> str: - """Return the context string for the given scope.""" - return self.mission_id if scope == "mission" else self.setup_version_id + def _resolve_context(self, context: ContextStorage) -> str: + """Resolve a context kind to its storage context string. + + MISSIONS/SETUP_VERSIONS map to the owner contexts this strategy was built + with. USERS/ORGANIZATIONS (read-only cross-owner) and UNSPECIFIED hold no + concrete id here, so they return a kind-only marker (`users:`, + `organizations:`, `unspecified:`); the storage service resolves the id — or + applies its default for UNSPECIFIED — server-side from the request metadata. + + Args: + context: The context kind to resolve. + + Returns: + The context string: `missions:`, `setup_versions:`, or the + kind marker `users:` / `organizations:` / `unspecified:`. + """ + match context: + case ContextStorage.MISSIONS: + return self.mission_id + case ContextStorage.SETUP_VERSIONS: + return self.setup_version_id + case _: + return f"{context.value}:" def _validate_data(self, collection: str, data: dict[str, Any]) -> BaseModel: """Validate data against the model schema for the given key. @@ -77,6 +98,7 @@ def _create_storage_record( validated_data: BaseModel, data_type: DataType, context: str, + visibility: Visibility, ) -> StorageRecord: """Create a storage record stamped with the given context. @@ -86,6 +108,7 @@ def _create_storage_record( validated_data: The validated data model data_type: The type of data context: Owner context to stamp on the record (mission or setup-version). + visibility: Read-access scope for the record. Returns: A complete storage record with metadata @@ -96,6 +119,7 @@ def _create_storage_record( record_id=record_id, data=validated_data, data_type=data_type, + visibility=visibility, ) @staticmethod @@ -120,14 +144,21 @@ async def _read(self, collection: str, record_id: str, context: str) -> StorageR Args: collection: The unique name to retrieve data for record_id: The unique ID of the record - context: Owner context (e.g. `missions:` or `setup_versions:`). + context: Resolved owner context (e.g. `missions:` or `setup_versions:`). Returns: A storage record with validated data """ @abstractmethod - async def _update(self, collection: str, record_id: str, data: BaseModel, context: str) -> StorageRecord | None: + async def _update( + self, + collection: str, + record_id: str, + data: BaseModel, + context: str, + visibility: Visibility = Visibility.UNSPECIFIED, + ) -> StorageRecord | None: """Overwrite an existing record's payload scoped to a specific context. Args: @@ -135,6 +166,7 @@ async def _update(self, collection: str, record_id: str, data: BaseModel, contex record_id: The unique ID of the record data: The new data to store context: Owner context for the record being updated. + visibility: New read-access scope; UNSPECIFIED leaves it unchanged. Returns: StorageRecord: The modified record @@ -154,12 +186,15 @@ async def _remove(self, collection: str, record_id: str, context: str) -> bool: """ @abstractmethod - async def _list(self, collection: str, context: str) -> list[StorageRecord]: + async def _list( + self, collection: str, context: str, visibilities: list[Visibility] | None = None + ) -> list[StorageRecord]: """List all records in a collection scoped to a specific context. Args: collection: The unique name for the record type context: Owner context filter. + visibilities: Optional read-access scopes to filter by (None = no filter). Returns: A list of storage records @@ -201,7 +236,7 @@ def _record_lock(self, context: str, collection: str, record_id: str) -> asyncio """Get or create an asyncio.Lock for a specific record under a given context. Args: - context: Owner context the record lives under + context: Resolved owner context string the record lives under. collection: The collection name record_id: The record ID @@ -215,8 +250,9 @@ async def store( collection: str, record_id: str | None, data: dict[str, Any], - data_type: Literal["OUTPUT", "VIEW", "LOGS", "OTHER"] = "OUTPUT", - scope: Scope = "mission", + data_type: DataType = DataType.OUTPUT, + context: ContextStorage = ContextStorage.MISSIONS, + visibility: Visibility = Visibility.UNSPECIFIED, ) -> StorageRecord: """Store a new record in the storage. @@ -225,8 +261,9 @@ async def store( record_id: The unique ID for the record (optional) data: The data to store data_type: The type of data being stored (default: OUTPUT) - scope: "mission" (default) writes under the current mission context; + context: "mission" (default) writes under the current mission context; "setup" writes under the setup-version context. + visibility: Read-access scope for the record (UNSPECIFIED = server default). Returns: The ID of the created record @@ -234,38 +271,41 @@ async def store( Raises: ValueError: If the data type is invalid or if validation fails """ - if not self._is_valid_data_type_name(data_type): + if not self._is_valid_data_type_name(data_type.value): msg = f"Invalid data type '{data_type}'. Must be one of {list(DataType.__members__.keys())}" raise ValueError(msg) record_id = record_id or uuid4().hex - data_type_enum = DataType[data_type] - context = self._resolve_context(scope) validated_data = self._validate_data(collection, data) - record = self._create_storage_record(collection, record_id, validated_data, data_type_enum, context) - async with self._record_lock(context, collection, record_id): + record = self._create_storage_record( + collection, record_id, validated_data, data_type, self._resolve_context(context), visibility + ) + async with self._record_lock(record.context, collection, record_id): return await self._store(record) - async def read(self, collection: str, record_id: str, scope: Scope = "mission") -> StorageRecord | None: + async def read( + self, collection: str, record_id: str, context: ContextStorage = ContextStorage.MISSIONS + ) -> StorageRecord | None: """Get a record by key under the given scope. Args: collection: The unique name to retrieve data for record_id: The unique ID of the record - scope: Which context to read from (default: "mission"). + context: Which context to read from (default: "mission"). Returns: The matching record if it exists, otherwise None. """ - context = self._resolve_context(scope) - async with self._record_lock(context, collection, record_id): - return await self._read(collection, record_id, context) + ctx = self._resolve_context(context) + async with self._record_lock(ctx, collection, record_id): + return await self._read(collection, record_id, ctx) async def update( self, collection: str, record_id: str, data: dict[str, Any], - scope: Scope = "mission", + context: ContextStorage = ContextStorage.MISSIONS, + visibility: Visibility = Visibility.UNSPECIFIED, ) -> StorageRecord | None: """Validate & overwrite an existing record under the given scope. @@ -273,60 +313,68 @@ async def update( collection: The unique name for the record type record_id: The unique ID of the record data: The new data to store - scope: Which context the record lives under (default: "mission"). + context: Which context the record lives under (default: "mission"). + visibility: New read-access scope; UNSPECIFIED leaves it unchanged. Returns: StorageRecord: The modified record """ validated_data = self._validate_data(collection, data) - context = self._resolve_context(scope) - async with self._record_lock(context, collection, record_id): - return await self._update(collection, record_id, validated_data, context) + ctx = self._resolve_context(context) + async with self._record_lock(ctx, collection, record_id): + return await self._update(collection, record_id, validated_data, ctx, visibility) - async def remove(self, collection: str, record_id: str, scope: Scope = "mission") -> bool: + async def remove(self, collection: str, record_id: str, context: ContextStorage = ContextStorage.MISSIONS) -> bool: """Delete a record from the storage under the given scope. Args: collection: The unique name for the record type record_id: The unique ID of the record - scope: Which context the record lives under (default: "mission"). + context: Which context the record lives under (default: "mission"). Returns: True if the deletion was successful, False otherwise """ - context = self._resolve_context(scope) - async with self._record_lock(context, collection, record_id): - result = await self._remove(collection, record_id, context) + ctx = self._resolve_context(context) + async with self._record_lock(ctx, collection, record_id): + result = await self._remove(collection, record_id, ctx) if result: - self._record_locks.pop(f"{context}|{collection}:{record_id}", None) + self._record_locks.pop(f"{ctx}|{collection}:{record_id}", None) return result - async def list(self, collection: str, scope: Scope = "mission") -> list[StorageRecord]: + async def list( + self, + collection: str, + context: ContextStorage = ContextStorage.MISSIONS, + visibilities: list[Visibility] | None = None, + ) -> list[StorageRecord]: """Get all records in a collection under the given scope. Args: collection: The unique name for the record type - scope: Which context to list (default: "mission"). + context: Which context to list (default: "mission"). "user"/"organization" + list across an owner and require `owner_id`. + visibilities: Optional read-access scopes to filter by (None = no filter). Returns: A list of storage records under the resolved context. """ - return await self._list(collection, self._resolve_context(scope)) + return await self._list(collection, self._resolve_context(context), visibilities) - async def remove_collection(self, collection: str, scope: Scope = "mission") -> bool: + async def remove_collection(self, collection: str, context: ContextStorage = ContextStorage.MISSIONS) -> bool: """Wipe a collection clean under the given scope. Args: collection: The unique name for the record type - scope: Which context the records live under (default: "mission"). + context: Which context the records live under (default: "mission"). Returns: True if the deletion was successful, False otherwise """ - context = self._resolve_context(scope) - result = await self._remove_collection(collection, context) + ctx = self._resolve_context(context) + result = await self._remove_collection(collection, ctx) if result: - prefix = f"{context}|{collection}:" + prefix = f"{ctx}|{collection}:" for key in [k for k in self._record_locks if k.startswith(prefix)]: self._record_locks.pop(key, None) return result @@ -336,8 +384,9 @@ async def upsert( collection: str, record_id: str, data: dict[str, Any], - data_type: Literal["OUTPUT", "VIEW", "LOGS", "OTHER"] = "OUTPUT", - scope: Scope = "mission", + data_type: DataType = DataType.OUTPUT, + context: ContextStorage = ContextStorage.MISSIONS, + visibility: Visibility = Visibility.UNSPECIFIED, ) -> StorageRecord: """Insert or update a record atomically under the given scope. @@ -350,7 +399,8 @@ async def upsert( record_id: The unique ID for the record data: The data to store data_type: The type of data being stored (default: OUTPUT) - scope: Which context to upsert under (default: "mission"). + context: Which context to upsert under (default: "mission"). + visibility: Read-access scope for the record (UNSPECIFIED = server default). Returns: The created or updated storage record @@ -359,18 +409,17 @@ async def upsert( ValueError: If the data type is invalid or if validation fails StorageServiceError: If update of an existing record fails unexpectedly """ - if not self._is_valid_data_type_name(data_type): + if not self._is_valid_data_type_name(data_type.value): msg = f"Invalid data type '{data_type}'. Must be one of {list(DataType.__members__.keys())}" raise ValueError(msg) - data_type_enum = DataType[data_type] - context = self._resolve_context(scope) validated_data = self._validate_data(collection, data) - async with self._record_lock(context, collection, record_id): - if await self._read(collection, record_id, context): - updated = await self._update(collection, record_id, validated_data, context) + ctx = self._resolve_context(context) + async with self._record_lock(ctx, collection, record_id): + if await self._read(collection, record_id, ctx): + updated = await self._update(collection, record_id, validated_data, ctx, visibility) if updated is None: msg = f"Update failed for existing record '{collection}:{record_id}'" raise StorageServiceError(msg) return updated - record = self._create_storage_record(collection, record_id, validated_data, data_type_enum, context) + record = self._create_storage_record(collection, record_id, validated_data, data_type, ctx, visibility) return await self._store(record) diff --git a/tests/community/agno/test_dynamic_tool_loading.py b/tests/community/agno/test_dynamic_tool_loading.py new file mode 100644 index 00000000..cd95f258 --- /dev/null +++ b/tests/community/agno/test_dynamic_tool_loading.py @@ -0,0 +1,255 @@ +"""Dynamic tool-loading tests that require the real agno dependency. + +Covered here (not in the fake-agno toolkit tests): use_setup is a real external-execution +Function, ``ToolLoaderTools.load`` builds/append a ModuleToolkit, and ``AgnoHitlRunner`` +resolves a use_setup pause in-process and auto-continues. +""" + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +pytest.importorskip("agno", reason="optional agno dependency not installed") + +from agno.models.response import ToolExecution + +from digitalkin.community.agno.hitl import AgnoHitlRunner +from digitalkin.community.agno.models import PauseInfo +from digitalkin.community.agno.toolkits import ToolLoaderTools + + +class _FakeModuleToolkit: + """Stand-in for ModuleToolkit — records the info it wraps, no agno introspection.""" + + def __init__(self, context: Any, info: Any) -> None: + self._context = context + self.tool_module_info = info + + +def _tool(name: str, args: dict[str, Any], *, result: str | None = None, tid: str = "tc1") -> ToolExecution: + return ToolExecution( + tool_call_id=tid, + tool_name=name, + tool_args=args, + external_execution_required=True, + result=result, + ) + + +async def _agen(*items: Any) -> Any: + for item in items: + yield item + + +def test_use_setup_is_registered_as_external_execution() -> None: + loader = ToolLoaderTools() + fn = loader.async_functions["use_setup"] + assert fn.external_execution is True + fn.process_entrypoint() + assert "setup_id" in (fn.parameters or {}).get("properties", {}) + + +@pytest.mark.asyncio +async def test_load_appends_module_toolkit_and_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + import digitalkin.community.agno.module_toolkit as mt + + monkeypatch.setattr(mt, "ModuleToolkit", _FakeModuleToolkit) + info = SimpleNamespace( + setup_id="s1", tool_name="Duda", module_name="tool-duda", slug="duda", tools=[SimpleNamespace(name="run")] + ) + send_message = AsyncMock() + context = SimpleNamespace(resolve_tool=AsyncMock(return_value=info), callbacks=SimpleNamespace(send_message=send_message)) + base_tools: list[Any] = [] + loader = ToolLoaderTools(context=context) # type: ignore[arg-type] + loader.bind_tools(base_tools) + + msg = await loader.load("s1") + + assert "loaded" in msg and "Duda" in msg + assert len(base_tools) == 1 + assert isinstance(base_tools[0], _FakeModuleToolkit) + send_message.assert_awaited() # a "tool_loaded" AG-UI event was emitted + + # Loading the same setup again does not duplicate the toolkit. + await loader.load("s1") + assert len(base_tools) == 1 + + +@pytest.mark.asyncio +async def test_load_rejects_setup_with_no_tools() -> None: + """A resolvable setup whose schema yields zero tools is a failure, not a phantom load.""" + info = SimpleNamespace(setup_id="s1", tool_name="Duda", module_name="tool-duda", slug="duda", tools=[]) + context = SimpleNamespace(resolve_tool=AsyncMock(return_value=info), callbacks=SimpleNamespace()) + base_tools: list[Any] = [] + loader = ToolLoaderTools(context=context) # type: ignore[arg-type] + loader.bind_tools(base_tools) + + msg = await loader.load("s1") + + assert "no callable tools" in msg + assert base_tools == [] + + +def _runner(tool_loader: Any = None, store: Any = None) -> AgnoHitlRunner: + return AgnoHitlRunner(agent=SimpleNamespace(), store=store or SimpleNamespace(), tool_loader=tool_loader) + + +class TestPausedToolHandling: + """Unit coverage for the runner's pause-classification helpers.""" + + @pytest.mark.asyncio + async def test_load_paused_tools_resolves_use_setup(self) -> None: + loader = SimpleNamespace(tool_name="use_setup", load=AsyncMock(return_value="loaded")) + runner = _runner(tool_loader=loader) + run_output = SimpleNamespace(tools=[_tool("use_setup", {"setup_id": "s1"})]) + + assert await runner._load_paused_tools(run_output) is True + assert run_output.tools[0].result == "loaded" + loader.load.assert_awaited_once_with("s1") + + @pytest.mark.asyncio + async def test_load_paused_tools_ignores_frontend_tools(self) -> None: + loader = SimpleNamespace(tool_name="use_setup", load=AsyncMock()) + runner = _runner(tool_loader=loader) + run_output = SimpleNamespace(tools=[_tool("frontend_tool", {})]) + + assert await runner._load_paused_tools(run_output) is False + loader.load.assert_not_called() + + @pytest.mark.asyncio + async def test_load_paused_tools_without_loader(self) -> None: + runner = _runner(tool_loader=None) + run_output = SimpleNamespace(tools=[_tool("use_setup", {"setup_id": "s1"})]) + assert await runner._load_paused_tools(run_output) is False + + def test_pending_external_reflects_unresolved_tools(self) -> None: + runner = _runner() + assert runner._pending_external(SimpleNamespace(tools=[_tool("f", {}, result=None)])) is True + assert runner._pending_external(SimpleNamespace(tools=[_tool("f", {}, result="done")])) is False + + +class _FakeAdapter: + """Reports a pause and passes no events through (drives _drive deterministically).""" + + is_paused = True + + def to_digitalkin_events(self, _event: Any) -> list[Any]: + return [] + + def flush(self) -> list[Any]: + return [] + + +class _FakeRunOutput: + """Minimal RunOutput: pause state, tools, messages, to_dict.""" + + def __init__(self, tools: list[Any], is_paused: bool) -> None: + self.tools = tools + self.is_paused = is_paused + self.messages: list[Any] = [] + + def to_dict(self) -> dict[str, Any]: + return {} + + +class TestDriveAutoContinue: + """The _drive loop: use_setup pauses auto-continue, frontend pauses persist.""" + + @pytest.mark.asyncio + async def test_use_setup_pause_auto_continues(self, monkeypatch: pytest.MonkeyPatch) -> None: + import digitalkin.community.agno.agno_adapter as aa + + monkeypatch.setattr(aa, "AgnoStreamAdapter", _FakeAdapter) + loader = SimpleNamespace(tool_name="use_setup", load=AsyncMock(return_value="loaded")) + completed = _FakeRunOutput(tools=[], is_paused=False) + agent = SimpleNamespace(acontinue_run=lambda **_: _agen(completed)) + store = SimpleNamespace(save=AsyncMock()) + runner = AgnoHitlRunner(agent=agent, store=store, tool_loader=loader) + paused = _FakeRunOutput(tools=[_tool("use_setup", {"setup_id": "s1"})], is_paused=True) + + result = await runner._drive( + stream=_agen(paused), send=AsyncMock(), thread_id="t1", run_output_cls=_FakeRunOutput, agui_tools=[] + ) + + assert result is None # ran to completion, no frontend round-trip + assert paused.tools[0].result == "loaded" + loader.load.assert_awaited_once_with("s1") + store.save.assert_not_called() + + @pytest.mark.asyncio + async def test_frontend_pause_is_persisted(self, monkeypatch: pytest.MonkeyPatch) -> None: + import digitalkin.community.agno.agno_adapter as aa + + monkeypatch.setattr(aa, "AgnoStreamAdapter", _FakeAdapter) + loader = SimpleNamespace(tool_name="use_setup", load=AsyncMock()) + store = SimpleNamespace( + save=AsyncMock(return_value=PauseInfo(thread_id="t1", run_id="r1", pending_tool_call_ids=["tc1"])) + ) + runner = AgnoHitlRunner(agent=SimpleNamespace(), store=store, tool_loader=loader) + paused = _FakeRunOutput(tools=[_tool("frontend_tool", {}, tid="tc1")], is_paused=True) + + result = await runner._drive( + stream=_agen(paused), send=AsyncMock(), thread_id="t1", run_output_cls=_FakeRunOutput, agui_tools=[] + ) + + assert result is not None + assert result.thread_id == "t1" + loader.load.assert_not_called() + store.save.assert_awaited_once() + + @pytest.mark.asyncio + async def test_auto_continue_limit_emits_run_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A model spinning use_setup forever ends with RUN_ERROR, not a silent stream death.""" + import digitalkin.community.agno.agno_adapter as aa + + from digitalkin.models.events import AgentRunEvent + + monkeypatch.setattr(aa, "AgnoStreamAdapter", _FakeAdapter) + loader = SimpleNamespace(tool_name="use_setup", load=AsyncMock(return_value="loaded")) + counter = iter(range(1000)) + + def _next_paused(**_: Any) -> Any: + return _agen(_FakeRunOutput(tools=[_tool("use_setup", {"setup_id": "s"}, tid=f"tc{next(counter)}")], is_paused=True)) + + agent = SimpleNamespace(acontinue_run=_next_paused) + store = SimpleNamespace(save=AsyncMock()) + runner = AgnoHitlRunner(agent=agent, store=store, tool_loader=loader) + send = AsyncMock() + + result = await runner._drive( + stream=_next_paused(), send=send, thread_id="t1", run_output_cls=_FakeRunOutput, agui_tools=[] + ) + + assert result is None + store.save.assert_not_called() + errors = [c.args[0] for c in send.await_args_list if c.args[0].event == AgentRunEvent.RUN_ERROR] + assert len(errors) == 1 + assert errors[0].error_type == "auto_continue_limit" + + +class TestRunnerLoaderAutoFind: + """The runner locates ToolLoaderTools in agent.tools when not passed explicitly.""" + + def test_finds_loader_in_tools_list(self) -> None: + loader = ToolLoaderTools() + agent = SimpleNamespace(tools=[SimpleNamespace(), loader]) + runner = AgnoHitlRunner(agent=agent, store=SimpleNamespace()) + assert runner._tool_loader is loader + + def test_finds_loader_via_tools_factory(self) -> None: + loader = ToolLoaderTools() + agent = SimpleNamespace(tools=lambda _run_context=None: [loader]) + runner = AgnoHitlRunner(agent=agent, store=SimpleNamespace()) + assert runner._tool_loader is loader + + def test_no_tools_attribute_stays_none(self) -> None: + runner = AgnoHitlRunner(agent=SimpleNamespace(), store=SimpleNamespace()) + assert runner._tool_loader is None + + def test_explicit_loader_wins(self) -> None: + explicit = ToolLoaderTools() + agent = SimpleNamespace(tools=[ToolLoaderTools()]) + runner = AgnoHitlRunner(agent=agent, store=SimpleNamespace(), tool_loader=explicit) + assert runner._tool_loader is explicit diff --git a/tests/community/agno/toolkits/__init__.py b/tests/community/agno/toolkits/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/community/agno/toolkits/conftest.py b/tests/community/agno/toolkits/conftest.py new file mode 100644 index 00000000..2520f9bf --- /dev/null +++ b/tests/community/agno/toolkits/conftest.py @@ -0,0 +1,56 @@ +"""Fixtures for the default toolkits tests. + +agno is not installed in the SDK test environment, but the toolkit modules +subclass ``agno.tools.Toolkit`` at import time. This conftest installs a fake +``agno.tools`` module BEFORE the test modules import the toolkits, and removes +the fakes (plus the toolkit modules bound to them) after the session so other +tests see a pristine ``sys.modules``. +""" + +import sys +import types +from typing import Any + +import pytest + + +class _FakeToolkit: + """Minimal stand-in for ``agno.tools.Toolkit``.""" + + def __init__(self, name: str = "", tools: list[Any] | None = None, **kwargs: Any) -> None: + self.name = name + self.tools = list(tools or []) + + +def _install_fake_agno() -> dict[str, Any]: + """Install fake agno modules into sys.modules, returning the displaced entries.""" + saved = {key: sys.modules.get(key) for key in ("agno", "agno.tools")} + agno_pkg = types.ModuleType("agno") + agno_tools = types.ModuleType("agno.tools") + agno_tools.Toolkit = _FakeToolkit # type: ignore[attr-defined] + agno_pkg.tools = agno_tools # type: ignore[attr-defined] + sys.modules["agno"] = agno_pkg + sys.modules["agno.tools"] = agno_tools + return saved + + +# Module-level install: conftest imports before the test modules in this directory, +# so their module-level toolkit imports resolve against the fake. +_SAVED_MODULES = _install_fake_agno() + + +@pytest.fixture(scope="session", autouse=True) +def _restore_agno_modules() -> Any: + """Remove the fake agno modules and the toolkit modules bound to them after the session. + + Yields: + None. Cleanup runs at session teardown. + """ + yield + for key, module in _SAVED_MODULES.items(): + if module is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = module + for key in [k for k in sys.modules if k.startswith("digitalkin.community.agno.toolkits")]: + sys.modules.pop(key, None) diff --git a/tests/community/agno/toolkits/test_base_toolkit.py b/tests/community/agno/toolkits/test_base_toolkit.py new file mode 100644 index 00000000..a8383e3f --- /dev/null +++ b/tests/community/agno/toolkits/test_base_toolkit.py @@ -0,0 +1,57 @@ +"""Tests for DkToolkit — canonical envelope + best-effort AG-UI notifications.""" + +import json +from types import SimpleNamespace +from typing import Any + +from digitalkin.community.agno.toolkits import DkToolkit + + +def test_ok_envelope() -> None: + assert json.loads(DkToolkit._ok({"a": 1}, tool="t")) == { + "output": {"a": 1}, + "metadata": {"success": True, "tool": "t"}, + } + + +def test_fail_envelope() -> None: + assert json.loads(DkToolkit._fail("boom", tool="t")) == { + "error": "boom", + "metadata": {"success": False, "tool": "t"}, + } + + +class _Kit(DkToolkit): + def __init__(self, context: Any = None) -> None: + super().__init__(name="k", tools=[], context=context) + + +async def test_notify_emits_agui_custom_event() -> None: + sent: list[Any] = [] + + async def _send(message: Any) -> None: + sent.append(message) + + ctx = SimpleNamespace(callbacks=SimpleNamespace(send_message=_send)) + await _Kit(ctx)._notify("live_view", {"url": "https://x"}) + + assert len(sent) == 1 + dumped = sent[0].model_dump(mode="json") # the callback contract (module_runner does this) + assert dumped["root"]["protocol"] == "agui_custom" + + +async def test_notify_noop_without_context() -> None: + await _Kit(None)._notify("x", 1) # no context -> silent no-op + + +async def test_notify_noop_without_callback() -> None: + ctx = SimpleNamespace(callbacks=SimpleNamespace()) # send_message not installed + await _Kit(ctx)._notify("x", 1) # silent no-op + + +async def test_notify_swallows_send_failure() -> None: + async def _boom(_message: Any) -> None: + raise RuntimeError("stream down") + + ctx = SimpleNamespace(callbacks=SimpleNamespace(send_message=_boom)) + await _Kit(ctx)._notify("x", 1) # best-effort: swallowed, never raises diff --git a/tests/community/agno/toolkits/test_chat_history_tools.py b/tests/community/agno/toolkits/test_chat_history_tools.py new file mode 100644 index 00000000..8f0e1cda --- /dev/null +++ b/tests/community/agno/toolkits/test_chat_history_tools.py @@ -0,0 +1,178 @@ +"""Tests for ChatHistoryTools — outline index, read-by-id, role filter, truncation, media, bind_host.""" + +import json +from typing import Any + +from digitalkin.community.agno.toolkits import ChatHistoryTools + + +class _FakeMedia: + """Stand-in for an agno media item (Image/Audio/Video/File).""" + + def __init__(self, media_id: str, mime_type: str, media_format: str, content: bytes = b"") -> None: + self.id = media_id + self.mime_type = mime_type + self.format = media_format + self.content = content + + +class _FakeMessage: + """Stand-in for ``agno.models.message.Message`` with the attributes the toolkit reads.""" + + def __init__( # noqa: PLR0913 + self, + role: str, + content: str, + message_id: str, + tool_name: str | None = None, + tool_call_error: bool = False, + images: list[Any] | None = None, + from_history: bool = False, + ) -> None: + self.role = role + self.content = content + self.id = message_id + self.created_at = 1234 + self.tool_name = tool_name + self.tool_call_error = tool_call_error + self.images = images + self.files = None + self.videos = None + self.audio = None + self.from_history = from_history + + def get_content_string(self) -> str: + return self.content + + +class _FakeHost: + """Stand-in for the bound Agent/Team; emulates Agno's ``skip_roles`` filtering.""" + + def __init__(self, messages: list[_FakeMessage]) -> None: + self._messages = messages + + async def aget_session_messages( + self, + session_id: str | None, + skip_roles: list[str], + skip_history_messages: bool, + ) -> list[_FakeMessage]: + return [m for m in self._messages if m.role not in skip_roles] + + +def _conversation() -> list[_FakeMessage]: + return [ + _FakeMessage("user", "First human question", "m0"), + _FakeMessage("assistant", "First AI answer", "m1"), + _FakeMessage("tool", "tool output", "m2", tool_name="search"), + _FakeMessage("user", "Second human question", "m3"), + _FakeMessage("assistant", "Second AI answer", "m4"), + ] + + +def _tools(messages: list[_FakeMessage]) -> ChatHistoryTools: + tools = ChatHistoryTools() + tools.host = _FakeHost(messages) + return tools + + +async def test_outline_empty_session_reports_total_zero() -> None: + tools = _tools([]) + result = json.loads(await tools.outline_chat_history()) + assert result["output"] == {"total": 0, "returned": 0, "offset": 0, "messages": []} + + +async def test_outline_first_returns_earliest() -> None: + tools = _tools(_conversation()) + result = json.loads(await tools.outline_chat_history(first=1))["output"] + assert result["total"] == 5 + assert result["returned"] == 1 + assert result["messages"][0]["id"] == "m0" + assert result["messages"][0]["ord"] == 0 + # metadata only — no full body field + assert "content" not in result["messages"][0] + + +async def test_outline_last_returns_most_recent() -> None: + tools = _tools(_conversation()) + result = json.loads(await tools.outline_chat_history(last=1))["output"] + assert result["messages"][0]["id"] == "m4" + + +async def test_outline_role_human_only() -> None: + tools = _tools(_conversation()) + result = json.loads(await tools.outline_chat_history(role="human"))["output"] + assert [m["id"] for m in result["messages"]] == ["m0", "m3"] + assert all(m["role"] == "human" for m in result["messages"]) + + +async def test_outline_role_tool_has_name() -> None: + tools = _tools(_conversation()) + result = json.loads(await tools.outline_chat_history(role="tool"))["output"] + assert result["messages"][0]["role"] == "tool" + assert result["messages"][0]["name"] == "search" + + +async def test_outline_invalid_role_errors() -> None: + tools = _tools(_conversation()) + result = json.loads(await tools.outline_chat_history(role="bogus")) + assert "error" in result + + +async def test_read_by_id_returns_content_and_missing() -> None: + tools = _tools(_conversation()) + result = json.loads(await tools.read_chat_messages(ids=["m0", "ghost"]))["output"] + assert result["missing"] == ["ghost"] + assert len(result["messages"]) == 1 + assert result["messages"][0]["id"] == "m0" + assert result["messages"][0]["content"] == "First human question" + + +async def test_read_truncates_long_body() -> None: + tools = _tools([_FakeMessage("assistant", "x" * 50, "big")]) + result = json.loads(await tools.read_chat_messages(ids=["big"], max_content_chars=10))["output"] + msg = result["messages"][0] + assert msg["truncated"] is True + assert msg["content"].startswith("x" * 10) + assert "[…truncated]" in msg["content"] + + +async def test_media_is_referenced_not_inlined() -> None: + image = _FakeMedia("img1", "image/png", "png", content=b"RAWBYTES") + tools = _tools([_FakeMessage("user", "see this", "m0", images=[image])]) + + outline = json.loads(await tools.outline_chat_history())["output"] + assert outline["messages"][0]["has_media"] is True + + read = json.loads(await tools.read_chat_messages(ids=["m0"])) + assert read["output"]["messages"][0]["media"] == [ + {"kind": "image", "id": "img1", "mime_type": "image/png", "format": "png"} + ] + # raw bytes must never leak into the tool result + assert "RAWBYTES" not in json.dumps(read) + + +async def test_unbound_host_reports_unavailable() -> None: + tools = ChatHistoryTools() # host left as None + result = json.loads(await tools.outline_chat_history()) + assert result["error"] == "chat history is not available" + + +def test_bind_host_on_raw_list() -> None: + tools = ChatHistoryTools() + host = object() + ChatHistoryTools.bind_host([object(), tools], host) + assert tools.host is host + + +def test_bind_host_resolves_factory_callable() -> None: + tools = ChatHistoryTools() + base = [tools] + host = object() + ChatHistoryTools.bind_host(lambda run_context=None: list(base), host) + assert tools.host is host + + +def test_bind_host_noop_without_instance() -> None: + ChatHistoryTools.bind_host([object()], object()) + ChatHistoryTools.bind_host(None, object()) diff --git a/tests/community/agno/toolkits/test_default_toolkits.py b/tests/community/agno/toolkits/test_default_toolkits.py new file mode 100644 index 00000000..e62919d0 --- /dev/null +++ b/tests/community/agno/toolkits/test_default_toolkits.py @@ -0,0 +1,56 @@ +"""Tests for the DefaultToolkits assembler.""" + +from types import SimpleNamespace +from typing import Any + +from digitalkin.community.agno.toolkits import ( + ChatHistoryTools, + DefaultToolkits, + RegistryTools, + SetupTools, + ToolLoaderTools, + UserProfileTools, +) +from digitalkin.services.registry import DefaultRegistry +from digitalkin.services.setup.default_setup import DefaultSetup +from digitalkin.services.user_profile import DefaultUserProfile + + +def _context(setup: Any = None) -> SimpleNamespace: + """Fake ModuleContext — build() touches user_profile, registry and setup.""" + return SimpleNamespace( + user_profile=DefaultUserProfile("missions:m1", "", ""), + registry=DefaultRegistry("", "", ""), + setup=setup, + ) + + +def test_build_without_setup_omits_setup_tools() -> None: + tools = DefaultToolkits.build(_context(), session_id="s1") # type: ignore[arg-type] + assert [type(t) for t in tools] == [ChatHistoryTools, UserProfileTools, RegistryTools, ToolLoaderTools] + + +def test_build_with_setup_includes_setup_tools_before_loader() -> None: + tools = DefaultToolkits.build(_context(setup=DefaultSetup()), session_id="s1") # type: ignore[arg-type] + assert [type(t) for t in tools] == [ + ChatHistoryTools, + UserProfileTools, + RegistryTools, + SetupTools, + ToolLoaderTools, + ] + + +def test_build_binds_loader_to_the_live_tool_list() -> None: + tools = DefaultToolkits.build(_context()) # type: ignore[arg-type] + loader = tools[-1] + assert isinstance(loader, ToolLoaderTools) + # The loader must append to the exact list the agent's factory closes over. + assert loader._base_tools is tools + + +def test_bind_host_wires_chat_history() -> None: + tools = DefaultToolkits.build(_context()) # type: ignore[arg-type] + host = object() + DefaultToolkits.bind_host(tools, host) + assert tools[0].host is host # type: ignore[union-attr] diff --git a/tests/community/agno/toolkits/test_registry_tools.py b/tests/community/agno/toolkits/test_registry_tools.py new file mode 100644 index 00000000..0d1f1c83 --- /dev/null +++ b/tests/community/agno/toolkits/test_registry_tools.py @@ -0,0 +1,244 @@ +"""Tests for RegistryTools — setup search safety filter, kind mapping, trimming.""" + +import json + +from digitalkin.community.agno.toolkits import RegistryTools +from digitalkin.grpc_servers.exceptions import PermissionDeniedError +from digitalkin.models.services.registry import ( + RegistryModuleType, + RegistrySetupStatus, + SetupInfo, +) +from digitalkin.services.registry import DefaultRegistry +from digitalkin.services.registry.exceptions import RegistryServiceError + + +def _registry() -> DefaultRegistry: + registry = DefaultRegistry("", "", "") + registry.add_setup( + SetupInfo( + setup_id="setups:duda", + name="Duda Builder", + documentation="Builds websites on the Duda platform. " + "x" * 400, + status=RegistrySetupStatus.READY, + module_id="modules:duda", + module_name="tool-duda", + module_type=RegistryModuleType.TOOL_MODULE, + setup_version="1.0.0", + config={"secret": "MUST-NOT-LEAK"}, + ) + ) + registry.add_setup( + SetupInfo( + setup_id="setups:isaac", + name="Isaac", + documentation="Multi-agent orchestration kin", + status=RegistrySetupStatus.CONFIGURATION_SUCCEEDED, + module_id="modules:isaac", + module_name="archetype-isaac", + module_type=RegistryModuleType.ARCHETYPE, + setup_version="2.0.0", + ) + ) + registry.add_setup( + SetupInfo( + setup_id="setups:draft", + name="Draft Tool", + status=RegistrySetupStatus.DRAFT, + module_type=RegistryModuleType.TOOL_MODULE, + ) + ) + return registry + + +async def test_search_setups_returns_only_invocable() -> None: + tools = RegistryTools(_registry()) + result = json.loads(await tools.search_setups()) + assert {s["setup_id"] for s in result["output"]["setups"]} == {"setups:duda", "setups:isaac"} + + +async def test_search_setups_kind_filter() -> None: + tools = RegistryTools(_registry()) + tool_result = json.loads(await tools.search_setups(kind="tool")) + assert [s["setup_id"] for s in tool_result["output"]["setups"]] == ["setups:duda"] + kin_result = json.loads(await tools.search_setups(kind="kin")) + assert [s["setup_id"] for s in kin_result["output"]["setups"]] == ["setups:isaac"] + + +async def test_search_setups_invalid_kind_errors() -> None: + tools = RegistryTools(_registry()) + assert "error" in json.loads(await tools.search_setups(kind="bogus")) + + +async def test_search_setups_never_emits_config() -> None: + tools = RegistryTools(_registry()) + raw = await tools.search_setups() + assert "config" not in raw + assert "MUST-NOT-LEAK" not in raw + + +async def test_search_setups_truncates_description() -> None: + tools = RegistryTools(_registry()) + result = json.loads(await tools.search_setups(query="duda")) + assert len(result["output"]["setups"][0]["description"]) == 300 + + +async def test_search_modules_kind_and_limit() -> None: + registry = _registry() + await registry.register("modules:duda", "localhost", 50051, "1.0.0", RegistryModuleType.TOOL_MODULE) + await registry.register("modules:isaac", "localhost", 50052, "2.0.0", RegistryModuleType.ARCHETYPE) + tools = RegistryTools(registry) + + result = json.loads(await tools.search_modules(kind="tool")) + assert [m["module_id"] for m in result["output"]["modules"]] == ["modules:duda"] + assert result["output"]["modules"][0]["kind"] == "tool_module" + # network location must never be surfaced to the LLM + assert "localhost" not in json.dumps(result) + + limited = json.loads(await tools.search_modules(limit=1)) + assert limited["output"]["total_returned"] == 1 + + +class _RaisingRegistry(DefaultRegistry): + """Registry whose searches always fail — exercises toolkit graceful degradation.""" + + async def search_setups(self, *args: object, **kwargs: object) -> list: + msg = "boom" + raise RegistryServiceError(msg) + + async def search(self, *args: object, **kwargs: object) -> list: + msg = "boom" + raise RegistryServiceError(msg) + + +async def test_search_setups_degrades_without_raising() -> None: + result = json.loads(await RegistryTools(_RaisingRegistry("", "", "")).search_setups()) + assert result["error"] + + +async def test_search_modules_degrades_without_raising() -> None: + result = json.loads(await RegistryTools(_RaisingRegistry("", "", "")).search_modules()) + assert result["error"] + + +async def test_search_setups_truncated_flag() -> None: + tools = RegistryTools(_registry()) + capped = json.loads(await tools.search_setups(limit=1)) + assert capped["output"]["truncated"] is True + assert capped["output"]["total_returned"] == 1 + full = json.loads(await tools.search_setups(limit=10)) + assert full["output"]["truncated"] is False + + +class _PermissionDeniedRegistry(DefaultRegistry): + """Registry whose searches are refused — exercises the permission surface.""" + + async def search_setups(self, *args: object, **kwargs: object) -> list: + raise PermissionDeniedError("denied") + + async def search(self, *args: object, **kwargs: object) -> list: + raise PermissionDeniedError("denied") + + +async def test_search_setups_permission_denied_is_distinct() -> None: + result = json.loads(await RegistryTools(_PermissionDeniedRegistry("", "", "")).search_setups()) + assert result["error"] == "permission denied: search_setups" + + +async def test_search_modules_permission_denied_is_distinct() -> None: + result = json.loads(await RegistryTools(_PermissionDeniedRegistry("", "", "")).search_modules()) + assert result["error"] == "permission denied: search_modules" + + +def _service_registry() -> DefaultRegistry: + registry = _registry() + registry.add_setup( + SetupInfo( + setup_id="setups:nikita", + name="Nikita", + documentation="Branding service", + status=RegistrySetupStatus.READY, + module_id="modules:nikita", + module_name="service-nikita", + module_type=RegistryModuleType.SERVICE, + setup_version="1.0.0", + ) + ) + return registry + + +async def test_search_setups_service_kind() -> None: + tools = RegistryTools(_service_registry()) + result = json.loads(await tools.search_setups(kind="service")) + assert [s["setup_id"] for s in result["output"]["setups"]] == ["setups:nikita"] + tool_result = json.loads(await tools.search_setups(kind="tool")) + assert "setups:nikita" not in [s["setup_id"] for s in tool_result["output"]["setups"]] + + +async def test_search_modules_service_kind() -> None: + registry = _registry() + await registry.register("modules:nikita", "localhost", 50053, "1.0.0", RegistryModuleType.SERVICE) + await registry.register("modules:duda", "localhost", 50051, "1.0.0", RegistryModuleType.TOOL_MODULE) + result = json.loads(await RegistryTools(registry).search_modules(kind="service")) + assert [m["module_id"] for m in result["output"]["modules"]] == ["modules:nikita"] + assert result["output"]["modules"][0]["kind"] == "service" + + +async def test_get_service_setup_returns_content_by_id() -> None: + """The discovery flow: search surfaces a setup_id, the tool reads its content.""" + toolkit = RegistryTools(_registry()) + assert any(tool.__name__ == "get_service_setup" for tool in toolkit.tools) + result = json.loads(await toolkit.get_service_setup("setups:duda")) + assert result["output"] == {"secret": "MUST-NOT-LEAK"} + + +async def test_get_service_setup_unknown_id_fails_cleanly() -> None: + result = json.loads(await RegistryTools(_registry()).get_service_setup("setups:absent")) + assert result["error"] == "service setup 'setups:absent' not found or has no content" + + +class _DeniedGetSetupRegistry(DefaultRegistry): + """Registry refusing get_setup — exercises the permission surface.""" + + async def get_setup(self, setup_id: str) -> SetupInfo | None: + msg = "denied" + raise PermissionDeniedError(msg) + + +async def test_get_service_setup_permission_denied_is_distinct() -> None: + result = json.loads(await RegistryTools(_DeniedGetSetupRegistry("", "", "")).get_service_setup("setups:duda")) + assert result["error"] == "permission denied: get_service_setup" + + +class _FailingGetSetupRegistry(DefaultRegistry): + """Registry whose get_setup always fails — exercises graceful degradation.""" + + async def get_setup(self, setup_id: str) -> SetupInfo | None: + msg = "boom" + raise RegistryServiceError(msg) + + +async def test_get_service_setup_degrades_without_raising() -> None: + result = json.loads(await RegistryTools(_FailingGetSetupRegistry("", "", "")).get_service_setup("setups:duda")) + assert result["error"] + + +class _EnumDriftRegistry(DefaultRegistry): + """Registry rejecting a filter enum (fail-closed encode) — a permanent condition.""" + + async def search_setups(self, *args: object, **kwargs: object) -> list: + msg = "no proto member MODULE_TYPE_X" + raise ValueError(msg) + + async def search_tools(self, *args: object, **kwargs: object) -> list: + msg = "no proto member MODULE_TYPE_X" + raise ValueError(msg) + + +async def test_enum_drift_is_not_reported_as_retryable() -> None: + tools = RegistryTools(_EnumDriftRegistry("", "", "")) + setups = json.loads(await tools.search_setups(kind="tool")) + modules = json.loads(await tools.search_modules(kind="tool")) + for result in (setups, modules): + assert result["error"] == "search filter not supported by this registry version" + assert "retry" not in result["error"] diff --git a/tests/community/agno/toolkits/test_setup_tools.py b/tests/community/agno/toolkits/test_setup_tools.py new file mode 100644 index 00000000..0296ee54 --- /dev/null +++ b/tests/community/agno/toolkits/test_setup_tools.py @@ -0,0 +1,254 @@ +"""Tests for SetupTools — setup CRUD + visibility over the module's shared setup service.""" + +import datetime +import json +from types import SimpleNamespace +from typing import Any +from unittest.mock import Mock + +import pytest + +from digitalkin.community.agno.toolkits import SetupTools +from digitalkin.grpc_servers.exceptions import PermissionDeniedError, ServerError +from digitalkin.services.setup.default_setup import DefaultSetup +from digitalkin.services.setup.exceptions import SetupServiceError +from digitalkin.services.setup.setup_strategy import SetupData, SetupStrategy, SetupVersionData + +_WHEN = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + + +def _setup(setup_id: str = "s1", name: str = "my setup") -> SetupData: + return SetupData( + id=setup_id, + name=name, + organisation_id="org1", + owner_id="owner1", + module_id="mod1", + status="READY", + visibility="VISIBILITY_PRIVATE", + current_setup_version=SetupVersionData( + id="v1", setup_id=setup_id, version="1.0.0", content={"k": "v"}, creation_date=_WHEN + ), + ) + + +class _RecordingSetup(SetupStrategy): + """Records the dict each op receives and returns a canned, backend-shaped value.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: + self.calls.append(("get_setup", setup_dict)) + return _setup(setup_dict["setup_id"]) + + async def create_setup(self, setup_dict: dict[str, Any]) -> SetupData: + self.calls.append(("create_setup", setup_dict)) + return _setup(name=setup_dict["name"]) + + async def update_setup(self, setup_dict: dict[str, Any]) -> SetupData: + self.calls.append(("update_setup", setup_dict)) + return _setup(setup_dict["setup_id"], name=setup_dict["name"]) + + async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: + self.calls.append(("delete_setup", setup_dict)) + return True + + async def change_visibility(self, setup_dict: dict[str, Any]) -> SetupData: + self.calls.append(("change_visibility", setup_dict)) + setup = _setup(setup_dict["setup_id"]) + setup.visibility = f"VISIBILITY_{setup_dict['visibility'].upper()}" + return setup + + +class _RaisingSetup(SetupStrategy): + """Every op raises the configured exception (for degradation tests).""" + + def __init__(self, exc: Exception) -> None: + self._exc = exc + + async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: + raise self._exc + + async def create_setup(self, setup_dict: dict[str, Any]) -> SetupData: + raise self._exc + + async def update_setup(self, setup_dict: dict[str, Any]) -> SetupData: + raise self._exc + + async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: + raise self._exc + + async def change_visibility(self, setup_dict: dict[str, Any]) -> SetupData: + raise self._exc + + +def _envelope(raw: str) -> dict[str, Any]: + return json.loads(raw) + + +class TestSetupToolsHappyPath: + """Each tool builds the expected dict and returns the success envelope.""" + + def test_exposed_surface(self) -> None: + """The agent sees exactly the 6 setup-level tools — no version RPCs, no list.""" + toolkit = SetupTools(_RecordingSetup()) + assert {fn.__name__ for fn in toolkit.tools} == { + "get_setup", + "create_setup", + "create_service", + "update_setup", + "delete_setup", + "change_visibility", + } + + @pytest.mark.asyncio + async def test_get_setup(self) -> None: + backend = _RecordingSetup() + env = _envelope(await SetupTools(backend).get_setup("s1", version="1.0.0")) + assert env["metadata"]["success"] is True + assert env["output"]["id"] == "s1" + assert env["output"]["status"] == "READY" + assert env["output"]["visibility"] == "VISIBILITY_PRIVATE" + assert backend.calls == [("get_setup", {"setup_id": "s1", "version": "1.0.0"})] + + @pytest.mark.asyncio + async def test_create_setup_sends_only_name_and_content(self) -> None: + backend = _RecordingSetup() + env = _envelope(await SetupTools(backend).create_setup("n", {"a": 1})) + assert env["metadata"]["success"] is True + assert env["output"]["name"] == "n" + # Owner/organisation/module derive server-side — the tool sends nothing else. + assert backend.calls == [("create_setup", {"name": "n", "content": {"a": 1}})] + + @pytest.mark.asyncio + async def test_update_setup(self) -> None: + backend = _RecordingSetup() + env = _envelope(await SetupTools(backend).update_setup("s1", "renamed", {"a": 2})) + assert env["output"]["name"] == "renamed" + assert backend.calls == [("update_setup", {"setup_id": "s1", "name": "renamed", "content": {"a": 2}})] + + @pytest.mark.asyncio + async def test_delete_setup(self) -> None: + backend = _RecordingSetup() + env = _envelope(await SetupTools(backend).delete_setup("s1")) + assert env["output"] is True + assert backend.calls == [("delete_setup", {"setup_id": "s1"})] + + @pytest.mark.asyncio + async def test_change_visibility(self) -> None: + backend = _RecordingSetup() + env = _envelope(await SetupTools(backend).change_visibility("s1", "public")) + assert env["output"]["visibility"] == "VISIBILITY_PUBLIC" + assert backend.calls == [("change_visibility", {"setup_id": "s1", "visibility": "public"})] + + +class TestSetupToolsDegradation: + """Every failure mode returns a structured error envelope — never raises.""" + + @pytest.mark.asyncio + async def test_permission_denied_is_distinct(self) -> None: + env = _envelope(await SetupTools(_RaisingSetup(PermissionDeniedError("x"))).get_setup("s1")) + assert env["metadata"]["success"] is False + assert env["error"] == "permission denied: get_setup" + + @pytest.mark.asyncio + @pytest.mark.parametrize("exc", [SetupServiceError("boom"), ServerError("down"), ValueError("bad")]) + async def test_service_errors_are_caught(self, exc: Exception) -> None: + env = _envelope(await SetupTools(_RaisingSetup(exc)).create_setup("n", {"a": 1})) + assert env["metadata"]["success"] is False + assert env["metadata"]["tool"] == "create_setup" + + @pytest.mark.asyncio + @pytest.mark.parametrize("exc", [KeyError("data"), TypeError("nope"), RuntimeError("boom")]) + async def test_unexpected_errors_never_raise(self, exc: Exception) -> None: + """Backend contract surprises (KeyError, ...) still land in a fail envelope.""" + env = _envelope(await SetupTools(_RaisingSetup(exc)).create_setup("n", {"a": 1})) + assert env["metadata"]["success"] is False + assert type(exc).__name__ in env["error"] + + @pytest.mark.asyncio + async def test_invalid_visibility_fails_cleanly(self) -> None: + env = _envelope(await SetupTools(DefaultSetup()).change_visibility("s1", "everyone")) # type: ignore[arg-type] + assert env["metadata"]["success"] is False + + +class TestSetupToolsInvalidation: + """Successful writes invalidate the servicer's setup cache via the context callback.""" + + @staticmethod + def _tools(backend: SetupStrategy) -> tuple[SetupTools, Mock]: + invalidate = Mock() + context = SimpleNamespace(callbacks=SimpleNamespace(invalidate_setup=invalidate)) + return SetupTools(backend, context=context), invalidate # type: ignore[arg-type] + + @pytest.mark.asyncio + async def test_writes_invalidate(self) -> None: + tools, invalidate = self._tools(_RecordingSetup()) + await tools.create_setup("n", {"a": 1}) + await tools.update_setup("s1", "n", {"a": 1}) + await tools.delete_setup("s1") + await tools.change_visibility("s1", "internal") + assert invalidate.call_count == 4 + + @pytest.mark.asyncio + async def test_reads_do_not_invalidate(self) -> None: + tools, invalidate = self._tools(_RecordingSetup()) + await tools.get_setup("s1") + invalidate.assert_not_called() + + @pytest.mark.asyncio + async def test_failed_write_does_not_invalidate(self) -> None: + tools, invalidate = self._tools(_RaisingSetup(SetupServiceError("boom"))) + await tools.update_setup("s1", "n", {"a": 1}) + invalidate.assert_not_called() + + @pytest.mark.asyncio + async def test_absent_callback_is_noop(self) -> None: + tools = SetupTools(_RecordingSetup(), context=SimpleNamespace(callbacks=SimpleNamespace())) # type: ignore[arg-type] + env = _envelope(await tools.create_setup("n", {"a": 1})) + assert env["metadata"]["success"] is True + + +class TestSetupToolsWithDefaultSetup: + """LOCAL-mode integration: the toolkit round-trips against the real DefaultSetup.""" + + @pytest.mark.asyncio + async def test_full_round_trip(self) -> None: + tools = SetupTools(DefaultSetup()) + + created = _envelope(await tools.create_setup("my setup", {"a": 1})) + assert created["metadata"]["success"] is True + setup_id = created["output"]["id"] + assert created["output"]["visibility"] == "VISIBILITY_PRIVATE" + + got = _envelope(await tools.get_setup(setup_id)) + assert got["output"]["name"] == "my setup" + assert got["output"]["current_setup_version"]["content"] == {"a": 1} + + updated = _envelope(await tools.update_setup(setup_id, "renamed", {"a": 2})) + assert updated["output"]["name"] == "renamed" + assert updated["output"]["current_setup_version"]["content"] == {"a": 2} + + shared = _envelope(await tools.change_visibility(setup_id, "internal")) + assert shared["output"]["visibility"] == "VISIBILITY_INTERNAL" + + assert _envelope(await tools.delete_setup(setup_id))["output"] is True + + @pytest.mark.asyncio + async def test_missing_ids_fail_cleanly(self) -> None: + tools = SetupTools(DefaultSetup()) + env = _envelope(await tools.get_setup("nope")) + assert env["metadata"]["success"] is False + + +class TestCreateService: + """create_service tool: name + content only, always registered.""" + + @pytest.mark.asyncio + async def test_creates_service(self) -> None: + tools = SetupTools(DefaultSetup()) + assert any(tool.__name__ == "create_service" for tool in tools.tools) + env = _envelope(await tools.create_service("Nikita", {"branding": True})) + assert env["output"]["name"] == "Nikita" + assert env["output"]["current_setup_version"]["content"] == {"branding": True} diff --git a/tests/community/agno/toolkits/test_tool_loader.py b/tests/community/agno/toolkits/test_tool_loader.py new file mode 100644 index 00000000..ec0a470f --- /dev/null +++ b/tests/community/agno/toolkits/test_tool_loader.py @@ -0,0 +1,82 @@ +"""Tests for ToolLoaderTools logic reachable without the real agno dependency. + +The success path of ``load`` (which builds a ModuleToolkit, needing real agno) and the +external-execution marking (needing a real agno Function) live in +``tests/community/agno/test_dynamic_tool_loading.py``. +""" + +import json +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from digitalkin.community.agno.toolkits import ToolLoaderTools +from digitalkin.grpc_servers.exceptions import PermissionDeniedError + + +def _loader(resolve: Any = None, base_tools: list[Any] | None = None) -> ToolLoaderTools: + """A ToolLoaderTools with a stub context and a bound (possibly empty) tool list.""" + context = SimpleNamespace(resolve_tool=resolve, callbacks=SimpleNamespace()) + loader = ToolLoaderTools(context=context) # type: ignore[arg-type] + if base_tools is not None: + loader.bind_tools(base_tools) + return loader + + +def test_tool_name_is_use_setup() -> None: + assert _loader().tool_name == "use_setup" + + +@pytest.mark.asyncio +async def test_use_setup_returns_pending_envelope() -> None: + env = json.loads(await _loader().use_setup("s1")) + assert env["metadata"]["success"] is True + assert env["output"] == {"setup_id": "s1", "status": "pending"} + + +def test_bind_tools_stores_the_live_list() -> None: + tools: list[Any] = [] + loader = _loader() + loader.bind_tools(tools) + assert loader._base_tools is tools + + +def test_find_locates_loader_in_list() -> None: + loader = _loader() + assert ToolLoaderTools.find(["x", loader, "y"]) is loader + + +def test_find_via_factory_callable() -> None: + loader = _loader() + assert ToolLoaderTools.find(lambda _ctx=None: [loader]) is loader + + +def test_find_absent_returns_none() -> None: + assert ToolLoaderTools.find(["x", "y"]) is None + + +@pytest.mark.asyncio +async def test_load_without_binding_is_unavailable() -> None: + # No base_tools bound → cannot append, so loading is unavailable. + loader = ToolLoaderTools(context=SimpleNamespace(resolve_tool=AsyncMock())) # type: ignore[arg-type] + assert "unavailable" in await loader.load("s1") + + +@pytest.mark.asyncio +async def test_load_permission_denied() -> None: + loader = _loader(resolve=AsyncMock(side_effect=PermissionDeniedError("no")), base_tools=[]) + assert await loader.load("s1") == "permission denied: cannot load setup s1" + + +@pytest.mark.asyncio +async def test_load_unknown_setup_not_found() -> None: + loader = _loader(resolve=AsyncMock(return_value=None), base_tools=[]) + assert await loader.load("s1") == "could not load setup s1: not found" + + +@pytest.mark.asyncio +async def test_load_resolution_error_is_swallowed() -> None: + loader = _loader(resolve=AsyncMock(side_effect=RuntimeError("boom")), base_tools=[]) + assert await loader.load("s1") == "could not load setup s1" diff --git a/tests/community/agno/toolkits/test_user_profile_tools.py b/tests/community/agno/toolkits/test_user_profile_tools.py new file mode 100644 index 00000000..82acd6c7 --- /dev/null +++ b/tests/community/agno/toolkits/test_user_profile_tools.py @@ -0,0 +1,58 @@ +"""Tests for UserProfileTools — lazy fetch, caching, and error retry.""" + +import json +from typing import Any + +from digitalkin.community.agno.toolkits import UserProfileTools +from digitalkin.services.user_profile import DefaultUserProfile, UserProfileServiceError, UserProfileStrategy + + +class _CountingProfile(UserProfileStrategy): + """Strategy that counts calls and can fail on demand.""" + + def __init__(self, profile: dict[str, Any] | None, fail_times: int = 0) -> None: + super().__init__("missions:m1", "", "") + self._profile = profile + self._fail_times = fail_times + self.calls = 0 + + async def get_user_profile(self) -> dict[str, Any] | None: + self.calls += 1 + if self._fail_times > 0: + self._fail_times -= 1 + msg = "boom" + raise UserProfileServiceError(msg) + return self._profile + + async def check_resource_access(self, resource_type: int, resource_id: str) -> bool: + return True + + +async def test_profile_returned_as_json() -> None: + strategy = DefaultUserProfile("missions:m1", "", "") + strategy.add_user_profile({"name": "Ada", "plan": "pro"}) + tools = UserProfileTools(strategy) + result = json.loads(await tools.get_user_profile()) + assert result["output"] == {"name": "Ada", "plan": "pro"} + + +async def test_missing_profile_reports_unavailable() -> None: + tools = UserProfileTools(DefaultUserProfile("missions:m1", "", "")) + result = json.loads(await tools.get_user_profile()) + assert result["error"] == "user profile is not available" + + +async def test_profile_fetched_once_across_calls() -> None: + strategy = _CountingProfile({"name": "Ada"}) + tools = UserProfileTools(strategy) + await tools.get_user_profile() + await tools.get_user_profile() + assert strategy.calls == 1 + + +async def test_service_error_retried_on_next_call() -> None: + strategy = _CountingProfile({"name": "Ada"}, fail_times=1) + tools = UserProfileTools(strategy) + assert json.loads(await tools.get_user_profile())["error"] == "user profile is not available" + assert json.loads(await tools.get_user_profile())["output"] == {"name": "Ada"} + assert strategy.calls == 2 diff --git a/tests/core/test_module_runner_m4.py b/tests/core/test_module_runner_m4.py index 5a85b0f5..147ec295 100644 --- a/tests/core/test_module_runner_m4.py +++ b/tests/core/test_module_runner_m4.py @@ -89,3 +89,94 @@ async def _on_fatal(code: str, message: str) -> None: # noqa: ARG001 eos = [x for x in redis.xadds if x[1].get("eos") == b"true"] assert len(eos) == 1 assert eos[0][2] is None + + +async def test_servicer_setup_is_borrowed_into_module_context() -> None: + """Constraint: the runner hands the servicer's setup service to preload_instance. + + The wiring must happen inside preload_instance (before prepare()/initialize() + builds the toolkits), so the runner passes the strategy + invalidation hook + as arguments instead of assigning context.setup after the fact. + """ + get_gateway_settings.cache_clear() + redis = _RecordingRedis() + + setup_version = MagicMock(content={}, setup_id="setups:s1", id="setup_versions:v1") + servicer = MagicMock() + servicer.resolve_setup = AsyncMock(return_value=setup_version) + servicer.module_class.create_setup_model = AsyncMock(return_value=MagicMock()) + servicer.get_tool_cache = MagicMock(return_value=MagicMock()) + servicer.module_class.create_input_model = MagicMock(return_value=MagicMock()) + + module = MagicMock() + preload_kwargs: dict[str, Any] = {} + + async def _preload(setup_data: Any, **kwargs: Any) -> tuple[Any, str, Any]: # noqa: ARG001 + preload_kwargs.update(kwargs) + return module, kwargs["job_id"], kwargs["callback"] + + async def _run_instance(**_: Any) -> None: + return + + servicer.job_manager.preload_instance = _preload + servicer.job_manager.run_instance = _run_instance + + runner = ModuleRunner(redis_client=redis, servicer=servicer) # type: ignore[arg-type] + + async def _on_fatal(code: str, message: str) -> None: # noqa: ARG001 + return + + with patch("digitalkin.core.task_manager.module_runner.TaskProfiler"): + await runner.run( + struct_pb2.Struct(), + task_id="t-setup", + setup_id="setups:s1", + mission_id="missions:m1", + on_fatal=_on_fatal, + ) + + assert preload_kwargs["setup"] is servicer.setup + assert preload_kwargs["invalidate_setup"] is servicer.invalidate_setup_cache + + +async def test_preload_wires_setup_before_prepare() -> None: + """SetupTools depends on context.setup being visible inside initialize(). + + ``prepare()`` (which runs ``initialize()``) must observe the borrowed setup + strategy and the invalidation callback — wiring them after preload would + silently drop SetupTools from every agent built in initialize(). + """ + from types import SimpleNamespace + + from digitalkin.core.job_manager.single_job_manager import SingleJobManager + + mgr = SingleJobManager.__new__(SingleJobManager) + mgr.module_class = MagicMock() + mgr._redis_task_manager = MagicMock() + + module = MagicMock() + module.context = SimpleNamespace(callbacks=SimpleNamespace(), setup=None, task_manager=None) + seen: dict[str, Any] = {} + + async def _prepare(setup_data: Any, callback: Any) -> None: # noqa: ARG001 + seen["setup"] = module.context.setup + seen["invalidate"] = vars(module.context.callbacks).get("invalidate_setup") + + module.prepare = _prepare + setup_strategy = object() + invalidate = MagicMock() + + with patch("digitalkin.core.job_manager.single_job_manager.ModuleFactory") as factory: + factory.create_module_instance.return_value = module + await mgr.preload_instance( + MagicMock(), + mission_id="missions:m1", + setup_id="setups:s1", + setup_version_id="setup_versions:v1", + callback=AsyncMock(), + setup=setup_strategy, + invalidate_setup=invalidate, + ) + + assert seen["setup"] is setup_strategy + assert seen["invalidate"] is invalidate diff --git a/tests/gateway/test_dial_consumer.py b/tests/gateway/test_dial_consumer.py index 76f8dede..7ad5ed6c 100644 --- a/tests/gateway/test_dial_consumer.py +++ b/tests/gateway/test_dial_consumer.py @@ -101,12 +101,14 @@ def __init__( extra_upstream: list[dict] | None = None, hang: bool = False, ignore_stream_end: bool = False, + hold_open: bool = False, ) -> None: self.received: list[Any] = [] self.query_data = query_data self.extra_upstream = extra_upstream or [] self.hang = hang self.ignore_stream_end = ignore_stream_end + self.hold_open = hold_open async def StartStream(self, request, context): return gateway_pb2.StartStreamResponse(accepted=False, task_id=request.task_id) @@ -140,6 +142,19 @@ async def Stream(self, request_iterator, context): ustruct.update(payload) yield gateway_pb2.StreamServer(seq=0, task_id=first.task_id, data=ustruct) + if self.hold_open: + # Drain in the background but NEVER close the response stream, even after + # the gateway half-closes its send side. This keeps the gateway's inbound + # read parked, so only the gateway's own close logic (bounded once outputs + # finish draining) can tear the BiDi down — the BUG 1 regression shape. + try: + async for msg in request_iterator: + self.received.append(msg) + except Exception: # noqa: BLE001 + return + await asyncio.sleep(3600) + return + # Drain any outputs the gateway pushes (it's pushing StreamClients to us). try: async for msg in request_iterator: @@ -545,3 +560,65 @@ async def test_dial_consumer_watchdog_closes_after_stream_end(self, gateway) -> finally: os.environ.pop("DIGITALKIN_GATEWAY_DIAL_BACK_CLOSE_GRACE_S", None) get_gateway_settings.cache_clear() + + @SKIP_NO_FAKEREDIS + async def test_dial_consumer_closes_after_fatal_when_consumer_holds_open(self, gateway) -> None: + """BUG 1 regression: after a SETUP_ACCESS_DENIED (fatal stream.error + EOS) finishes + draining, the dial-back BiDi tears down within ``dial_back_close_grace_s`` even when the + consumer holds its response stream open — instead of parking to ``dial_back_max_lifetime_s``. + + Pre-fix, the receive loop commits to an unbounded inbound read before ``outgoing_done`` + fires and never re-evaluates, so it blocks to the lifetime ceiling. + """ + import os + + from digitalkin.models.settings.gateway import get_gateway_settings + + os.environ["DIGITALKIN_GATEWAY_DIAL_BACK_CLOSE_GRACE_S"] = "0.2" + os.environ["DIGITALKIN_GATEWAY_DIAL_BACK_MAX_LIFETIME_S"] = "3.0" + get_gateway_settings.cache_clear() + try: + servicer = _FakeConsumerServicer( + query_data={"protocol": "agui_stream", "user_prompt": "hi"}, + hold_open=True, + ) + server = grpc.aio.server() + gateway_service_pb2_grpc.add_GatewayServiceServicer_to_server(servicer, server) + port = server.add_insecure_port("127.0.0.1:0") + await server.start() + try: + task_id = "task_fatal_hold" + stream_key = f"task:{task_id}:stream" + err = struct_pb2.Struct() + err.update({ + "root": { + "protocol": "stream.error", + "code": "SETUP_ACCESS_DENIED", + "message": "denied", + "fatal": True, + } + }) + await gateway._redis_client.xadd(stream_key, {"pb": err.SerializeToString(), "seq": "1"}) + await gateway._redis_client.xadd(stream_key, {"eos": b"true"}) + + ctx = _mock_context({"x-client-address": f"127.0.0.1:{port}"}) + t0 = asyncio.get_event_loop().time() + await gateway.StartStream(_start_request(task_id), ctx) + for _ in range(80): + if gateway._registry.get(task_id) is None: + break + await asyncio.sleep(0.05) + elapsed = asyncio.get_event_loop().time() - t0 + + assert gateway._registry.get(task_id) is None, ( + "dial-back never finished — the BiDi hung past close_grace (BUG 1)" + ) + # Fix bounds teardown by close_grace (0.2s); the regression parks to + # max_lifetime (3.0s). A threshold well below 3.0s proves the fix. + assert elapsed < 1.5, f"dial-back took {elapsed:.2f}s — expected teardown near close_grace" + finally: + await server.stop(grace=0.1) + finally: + os.environ.pop("DIGITALKIN_GATEWAY_DIAL_BACK_CLOSE_GRACE_S", None) + os.environ.pop("DIGITALKIN_GATEWAY_DIAL_BACK_MAX_LIFETIME_S", None) + get_gateway_settings.cache_clear() diff --git a/tests/gateway/test_tool_cache_servicer.py b/tests/gateway/test_tool_cache_servicer.py index 0fc105c8..225923a3 100644 --- a/tests/gateway/test_tool_cache_servicer.py +++ b/tests/gateway/test_tool_cache_servicer.py @@ -17,7 +17,7 @@ def test_prebuilt_stored_on_module(self) -> None: prebuilt = ToolCache() prebuilt.add(ToolModuleInfo( - module_id="mod:1", module_type="tool", address="localhost", + module_id="mod:1", module_type="tool_module", address="localhost", port=50055, setup_id="setups:test", tool_name="TestTool", )) diff --git a/tests/modules/test_registry_documentation.py b/tests/modules/test_registry_documentation.py new file mode 100644 index 00000000..e4fa2a18 --- /dev/null +++ b/tests/modules/test_registry_documentation.py @@ -0,0 +1,84 @@ +"""Registry documentation assembly: enforced author description + LLM trigger table.""" + +from typing import Literal +from unittest.mock import Mock + +import pytest +from pydantic import BaseModel + +from digitalkin.models.module.base_types import DataModel, DataTrigger +from digitalkin.models.module.module_types import SetupModel +from digitalkin.modules._base_module import BaseModule +from digitalkin.services.registry import DefaultRegistry +from digitalkin.utils.package_discover import ModuleDiscoverer + + +class _InputTrigger(DataTrigger): + protocol: Literal["message"] = "message" + text: str = "" + + +class _InputModel(DataModel[_InputTrigger]): + pass + + +class _SetupModel(SetupModel): + pass + + +class _SecretModel(BaseModel): + pass + + +def _module(description: str = "Does a specific thing.", *, metadata_desc: str | None = None) -> type[BaseModule]: + meta: dict = {"module_id": "modules:test"} + if metadata_desc is not None: + meta["description"] = metadata_desc + + class _Mod(BaseModule[_InputModel, _InputModel, _SetupModel, _SecretModel]): + name = "test_mod" + setup_format = _SetupModel + input_format = _InputModel + output_format = _InputModel + secret_format = _SecretModel + metadata = meta + triggers_discoverer = ModuleDiscoverer("test") + + async def initialize(self, context, setup_data) -> None: + pass + + async def cleanup(self) -> None: + pass + + _Mod.description = description + handler = Mock() + handler.protocol = "message" + handler.description = "Handle a chat message" + handler.input_format = _InputTrigger + _Mod.triggers_discoverer._trigger_handlers_cls["message"] = [handler] + return _Mod + + +def test_documentation_has_description_and_trigger_table() -> None: + doc = _module(description="Specialised summariser archetype.").build_registry_documentation() + assert doc.startswith("Specialised summariser archetype.") + assert "## Triggers" in doc + assert "| Trigger | Description |" in doc + assert "| message | Handle a chat message |" in doc + + +def test_empty_description_raises() -> None: + with pytest.raises(ValueError, match="non-empty 'description'"): + _module(description="").build_registry_documentation() + + +def test_metadata_description_fallback() -> None: + doc = _module(description="", metadata_desc="Blurb from metadata.").build_registry_documentation() + assert doc.startswith("Blurb from metadata.") + + +async def test_default_registry_stores_documentation() -> None: + registry = DefaultRegistry("", "", "") + info = await registry.register("modules:x", "localhost", 50051, "1.0.0", documentation="indexed docs") + assert info is not None + assert info.documentation == "indexed docs" diff --git a/tests/modules/test_tool_cache.py b/tests/modules/test_tool_cache.py index 80cb24d5..c7d96c27 100644 --- a/tests/modules/test_tool_cache.py +++ b/tests/modules/test_tool_cache.py @@ -4,10 +4,13 @@ import pytest +from digitalkin.grpc_servers.exceptions import PermissionDeniedError +from digitalkin.models.module.module_context import ModuleContext, Session from digitalkin.models.module.setup_types import SetupModel from digitalkin.models.module.tool_cache import ToolCache, ToolDefinition, ToolModuleInfo from digitalkin.models.module.tool_reference import ToolReference, ToolSelection from digitalkin.models.services.registry import ModuleInfo, RegistryModuleType, SetupInfo +from digitalkin.services.registry.exceptions import RegistryModuleNotFoundError @pytest.fixture @@ -15,7 +18,7 @@ def sample_tool_module_info() -> ToolModuleInfo: """Create a sample ToolModuleInfo for testing.""" return ToolModuleInfo( module_id="tool-123", - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=50051, version="1.0.0", @@ -42,7 +45,7 @@ def sample_tool_module_info_2() -> ToolModuleInfo: """Create a second sample ToolModuleInfo for testing.""" return ToolModuleInfo( module_id="tool-456", - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=50052, version="2.0.0", @@ -118,6 +121,20 @@ def test_get_without_cache_returns_none(self) -> None: class TestSetupModelToolCache: """Tests for SetupModel tool cache integration.""" + def test_legacy_resolved_tools_vocabulary_parses(self) -> None: + """Setup content persisted by older SDKs (module_type 'tool') still validates. + + Prod repro: stored setups carry resolved_tools entries with the legacy + 'tool' label; the alias validator normalizes them instead of killing the run. + """ + setup = SetupModel( + resolved_tools={ + "setups:legacy": {"module_type": "tool", "setup_id": "setups:legacy", "tool_name": "Legacy"}, + } + ) + assert setup.resolved_tools["setups:legacy"].module_type == RegistryModuleType.TOOL_MODULE + assert "resolved_tools" not in setup.model_dump() # exclude=True: never re-serialized + @pytest.mark.asyncio async def test_build_tool_cache_from_resolved_tools(self, sample_tool_module_info: ToolModuleInfo) -> None: """Test building tool cache from resolved tool references.""" @@ -234,7 +251,7 @@ def _registry_resolving(setup_id: str, module_id: str, name: str) -> AsyncMock: registry.get_setup.return_value = SetupInfo(setup_id=setup_id, name=name, module_id=module_id) registry.discover_by_id.return_value = ModuleInfo( module_id=module_id, - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=50051, version="1.0.0", @@ -279,7 +296,7 @@ class TestSetup(SetupModel): # Stale empty entry, as would be loaded from persisted content. setup.resolved_tools["setup-123"] = ToolModuleInfo( module_id="tool-123", - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=50051, version="1.0.0", @@ -362,7 +379,7 @@ class TestSetup(SetupModel): ) mock_registry.discover_by_id.return_value = ModuleInfo( module_id="tool-123", - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=50051, version="1.0.0", @@ -449,7 +466,7 @@ class TestSetup(SetupModel): ) mock_registry.discover_by_id.side_effect = lambda module_id: ModuleInfo( module_id=module_id, - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=50051, version="1.0.0", @@ -534,6 +551,95 @@ def test_slug_no_setup_id(self) -> None: assert info.slug == "my_tool" +def _context_with(registry: AsyncMock, communication: AsyncMock) -> ModuleContext: + """Build a bare ModuleContext exposing only what ``resolve_tool`` touches.""" + ctx = ModuleContext.__new__(ModuleContext) + ctx.tool_cache = ToolCache() + ctx.registry = registry + ctx.communication = communication + ctx.session = Session(job_id="job-1", mission_id="mission-1", setup_id="setup-1", setup_version_id="sv-1") + return ctx + + +class TestModuleContextResolveTool: + """Tests for ModuleContext.resolve_tool — the on-demand tool loader.""" + + @pytest.mark.asyncio + async def test_resolves_and_saves_to_tool_cache(self) -> None: + """A resolved setup lands in the tool cache (constraint: loaded tools are cached).""" + ctx = _context_with(_registry_resolving("setup-123", "tool-123", "TestTool"), _communication_with_search()) + + info = await ctx.resolve_tool("setup-123") + + assert info is not None + assert info.setup_id == "setup-123" + assert ctx.tool_cache.entries["setup-123"] is info + + @pytest.mark.asyncio + async def test_cache_hit_still_checks_permission(self) -> None: + """A cache hit skips discovery/schema fetch but never the get_setup authz gate. + + The tool cache is shared across missions of the same agent setup, so + skipping get_setup on a hit would let one mission's load bypass another + mission's permission check. + """ + registry = _registry_resolving("setup-123", "tool-123", "TestTool") + communication = _communication_with_search() + ctx = _context_with(registry, communication) + + first = await ctx.resolve_tool("setup-123") + registry.discover_by_id.reset_mock() + communication.get_module_schemas.reset_mock() + second = await ctx.resolve_tool("setup-123") + + assert second is first + assert registry.get_setup.await_count == 2 + registry.discover_by_id.assert_not_called() + communication.get_module_schemas.assert_not_called() + + @pytest.mark.asyncio + async def test_cache_hit_denied_when_permission_revoked(self) -> None: + """PermissionDeniedError on a cached setup_id still surfaces — the hit is gated.""" + registry = _registry_resolving("setup-123", "tool-123", "TestTool") + ctx = _context_with(registry, _communication_with_search()) + + await ctx.resolve_tool("setup-123") + registry.get_setup.side_effect = PermissionDeniedError("revoked") + + with pytest.raises(PermissionDeniedError): + await ctx.resolve_tool("setup-123") + + @pytest.mark.asyncio + async def test_permission_denied_propagates(self) -> None: + """PermissionDeniedError is not swallowed — callers surface it distinctly.""" + registry = AsyncMock() + registry.get_setup.side_effect = PermissionDeniedError("nope") + ctx = _context_with(registry, AsyncMock()) + + with pytest.raises(PermissionDeniedError): + await ctx.resolve_tool("setup-123") + + @pytest.mark.asyncio + async def test_unknown_setup_returns_none(self) -> None: + """A setup the registry cannot resolve yields None (not an exception).""" + registry = AsyncMock() + registry.get_setup.return_value = None + ctx = _context_with(registry, AsyncMock()) + + assert await ctx.resolve_tool("missing") is None + + @pytest.mark.asyncio + async def test_missing_module_returns_none(self) -> None: + """A setup whose backing module is gone yields None and caches nothing.""" + registry = AsyncMock() + registry.get_setup.return_value = SetupInfo(setup_id="setup-123", name="X", module_id="tool-123") + registry.discover_by_id.side_effect = RegistryModuleNotFoundError("gone") + ctx = _context_with(registry, AsyncMock()) + + assert await ctx.resolve_tool("setup-123") is None + assert ctx.tool_cache.entries == {} + + class TestToolCacheCollision: """Tests for ToolCache setup_id-based keying.""" diff --git a/tests/modules/test_tool_function_fatal.py b/tests/modules/test_tool_function_fatal.py new file mode 100644 index 00000000..3f83d3f4 --- /dev/null +++ b/tests/modules/test_tool_function_fatal.py @@ -0,0 +1,98 @@ +"""BUG 2 regression: a tool's fatal stream.error aborts the tool call. + +A fatal ``stream.error`` (e.g. SETUP_ACCESS_DENIED) yielded by ``call_module`` must be +raised as ``ToolCallError`` from the tool function, not surfaced as a benign result dict — +otherwise the parent run never reaches a terminal state and its dial-back BiDi hangs. +""" + +from typing import Any + +import pytest +from google.protobuf import struct_pb2 + +from digitalkin.models.module.module_context import ModuleContext, Session +from digitalkin.models.module.tool_cache import ToolDefinition, ToolModuleInfo +from digitalkin.models.services.registry import RegistryModuleType +from digitalkin.services.communication.exceptions import ToolCallError + + +def _frame(root: dict[str, Any]) -> struct_pb2.Struct: + s = struct_pb2.Struct() + s.update({"root": root}) + return s + + +class _FakeComm: + """Communication stub whose ``call_module`` replays preset Struct frames.""" + + def __init__(self, frames: list[struct_pb2.Struct]) -> None: + self._frames = frames + + async def call_module(self, **_kwargs: Any) -> Any: + for frame in self._frames: + yield frame + + +def _tool_function(frames: list[struct_pb2.Struct]) -> Any: + tmi = ToolModuleInfo( + module_id="tool-1", + module_type=RegistryModuleType.TOOL_MODULE, + address="localhost", + port=50051, + version="1.0.0", + module_name="SearchTool", + setup_id="setup-1", + tools=[ToolDefinition(name="search", description="Search")], + ) + session = Session(job_id="jobs:1", mission_id="missions:1", setup_id="setup-1", setup_version_id="v1") + return ModuleContext._create_single_tool_function( + _FakeComm(frames), # type: ignore[arg-type] + session, + tmi, + tmi.tools[0], + ) + + +@pytest.mark.asyncio +async def test_fatal_stream_error_raises_tool_call_error() -> None: + fn = _tool_function([ + _frame({"protocol": "message", "content": "partial"}), + _frame({"protocol": "stream.error", "code": "SETUP_ACCESS_DENIED", "message": "denied", "fatal": True}), + ]) + seen: list[dict] = [] + + async def _drain() -> None: + async for out in fn(): + seen.append(out) # noqa: PERF401 # frames before the fatal must survive the raise + + with pytest.raises(ToolCallError, match=r"\[SETUP_ACCESS_DENIED\].*denied") as exc: + await _drain() + # The non-fatal frame before it is still delivered; the fatal one aborts. + assert seen == [{"root": {"protocol": "message", "content": "partial"}}] + assert "[SETUP_ACCESS_DENIED]" in str(exc.value) + + +@pytest.mark.asyncio +async def test_non_fatal_stream_error_is_yielded() -> None: + fn = _tool_function([ + _frame({"protocol": "stream.error", "code": "TRANSIENT", "message": "retrying", "fatal": False}), + _frame({"protocol": "message", "content": "done"}), + ]) + seen = [out async for out in fn()] + assert seen == [ + {"root": {"protocol": "stream.error", "code": "TRANSIENT", "message": "retrying", "fatal": False}}, + {"root": {"protocol": "message", "content": "done"}}, + ] + + +@pytest.mark.asyncio +async def test_clean_run_yields_all_frames() -> None: + fn = _tool_function([ + _frame({"protocol": "message", "content": "a"}), + _frame({"protocol": "message", "content": "b"}), + ]) + seen = [out async for out in fn()] + assert seen == [ + {"root": {"protocol": "message", "content": "a"}}, + {"root": {"protocol": "message", "content": "b"}}, + ] diff --git a/tests/modules/test_tool_reference.py b/tests/modules/test_tool_reference.py index ac8f06a9..b607db03 100644 --- a/tests/modules/test_tool_reference.py +++ b/tests/modules/test_tool_reference.py @@ -17,6 +17,8 @@ ModuleInfo, RegistryModuleStatus, RegistryModuleType, + RegistrySetupStatus, + RegistryVisibility, SetupInfo, ) from digitalkin.services.registry import RegistryStrategy @@ -53,7 +55,8 @@ async def search( self, name: str | None = None, module_type: str | None = None, - organization_id: str | None = None, + limit: int = 20, + offset: int = 0, ) -> list[ModuleInfo]: if name and name in self._search_results: return self._search_results[name] @@ -62,12 +65,26 @@ async def search( async def get_status(self, module_id: str) -> None: return None + async def search_setups( # noqa: PLR0913 + self, + query: str | None = None, + setup_ids: list[str] | None = None, + module_ids: list[str] | None = None, + module_types: list[RegistryModuleType] | None = None, + statuses: list[RegistrySetupStatus] | None = None, + visibilities: list[RegistryVisibility] | None = None, + limit: int = 20, + offset: int = 0, + ) -> list[SetupInfo]: + return [] + async def register( self, module_id: str, address: str, port: int, version: str, + module_type: RegistryModuleType = RegistryModuleType.UNSPECIFIED, ) -> ModuleInfo | None: return None @@ -110,7 +127,7 @@ def create_tool_module_info( """Create a ToolModuleInfo for testing.""" return ToolModuleInfo( module_id=module_id, - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=port, version="1.0.0", @@ -135,7 +152,7 @@ def create_tool_module_info( def search_tool_info() -> ModuleInfo: return ModuleInfo( module_id="tool-search-001", - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=50051, version="1.0.0", @@ -147,7 +164,7 @@ def search_tool_info() -> ModuleInfo: def analyzer_tool_info() -> ModuleInfo: return ModuleInfo( module_id="tool-analyzer-002", - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=50052, version="2.0.0", @@ -159,7 +176,7 @@ def analyzer_tool_info() -> ModuleInfo: def writer_tool_info() -> ModuleInfo: return ModuleInfo( module_id="tool-writer-003", - module_type=RegistryModuleType.TOOL, + module_type=RegistryModuleType.TOOL_MODULE, address="localhost", port=50053, version="1.5.0", diff --git a/tests/services/filesystem/mock_filesystem_servicer.py b/tests/services/filesystem/mock_filesystem_servicer.py index de6f74eb..9896cee2 100644 --- a/tests/services/filesystem/mock_filesystem_servicer.py +++ b/tests/services/filesystem/mock_filesystem_servicer.py @@ -60,6 +60,21 @@ def _model_to_proto(self, model: dict[str, Any]) -> filesystem_pb2.File: status=status, ) + @staticmethod + def _resolve_context(kind: int) -> str: + """Resolve a context KIND enum to the concrete test id. + + Mirrors the dev4 server contract: requests carry only the ContextFile kind; + the concrete id is resolved server-side — here from the fixed test ids. + + Args: + kind: ContextFile enum value from the request. + + Returns: + The concrete context id string. + """ + return "setup" if kind == filesystem_pb2.CONTEXT_SETUP else "test_mission" + def _generate_url(self, context: str, name: str) -> str: """Generate a fake URL for a file. @@ -91,7 +106,7 @@ def UploadFiles( total_failed = 0 for file_data in request.files: - context = file_data.context + context = self._resolve_context(file_data.context) name = file_data.name # Initialize the context dict if it doesn't exist @@ -172,7 +187,7 @@ def GetFile( filesystem_pb2.GetFileResponse: The response containing the file """ try: - context = request.context + context = self._resolve_context(request.context) file_id = request.file_id # Check if context exists @@ -216,8 +231,10 @@ def GetFiles( filesystem_pb2.GetFilesResponse: The response containing matching files """ try: - context = request.context - filters = FileFilter(**MessageToDict(request.filters)) + context = self._resolve_context(request.context) + raw_filters = MessageToDict(request.filters) + raw_filters["context"] = "setup" if request.filters.context == filesystem_pb2.CONTEXT_SETUP else "mission" + filters = FileFilter(**raw_filters) # Check if context exists if context not in self.files: @@ -294,7 +311,7 @@ def UpdateFile( filesystem_pb2.UpdateFileResponse: The response containing the updated file """ try: - context = request.context + context = self._resolve_context(request.context) file_id = request.file_id # Check if context exists @@ -360,8 +377,10 @@ def DeleteFiles( filesystem_pb2.DeleteFilesResponse: The response indicating success or failure """ try: - context = request.context - filters = FileFilter(**MessageToDict(request.filters)) + context = self._resolve_context(request.context) + raw_filters = MessageToDict(request.filters) + raw_filters["context"] = "setup" if request.filters.context == filesystem_pb2.CONTEXT_SETUP else "mission" + filters = FileFilter(**raw_filters) permanent = request.permanent # Check if context exists diff --git a/tests/services/filesystem/test_grpc_filesystem.py b/tests/services/filesystem/test_grpc_filesystem.py index 5dc39db5..d10af57f 100644 --- a/tests/services/filesystem/test_grpc_filesystem.py +++ b/tests/services/filesystem/test_grpc_filesystem.py @@ -18,6 +18,7 @@ from grpc.framework.foundation import logging_pool from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.services.filesystem import ContextFile from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.filesystem.exceptions import FilesystemServiceError from digitalkin.services.filesystem.filesystem_strategy import ( @@ -288,7 +289,7 @@ def test_upload_files_duplicate_error( upload_request = filesystem_pb2.UploadFilesRequest( files=[ filesystem_pb2.UploadFileData( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, name=file_metadata["name"], file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), content_type=file_metadata["content_type"], @@ -348,7 +349,7 @@ def test_get_file_success( upload_request = filesystem_pb2.UploadFilesRequest( files=[ filesystem_pb2.UploadFileData( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, name=file_metadata["name"], file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), content_type=file_metadata["content_type"], @@ -374,7 +375,7 @@ def test_get_file_success( # Create a request object for the mock servicer get_request = filesystem_pb2.GetFileRequest( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, file_id=file_id, include_content=False, ) @@ -457,7 +458,7 @@ def test_get_files_success( upload_files = [ filesystem_pb2.UploadFileData( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, name=name, file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), content_type=file_metadata["content_type"], @@ -497,9 +498,9 @@ def test_get_files_success( # Create a request object for the mock servicer get_request = filesystem_pb2.GetFilesRequest( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, filters=filesystem_pb2.FileFilter( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, file_types=[GrpcFilesystem._file_type_to_enum(file_metadata["file_type"])], status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), ), @@ -550,16 +551,6 @@ def test_get_files_success( ) _, _, rpc = test_channel.take_unary_unary(method_desc) - filesystem_pb2.GetFilesRequest( - context="nonexistent_context", - filters=filesystem_pb2.FileFilter( - context="nonexistent_context", - file_types=[GrpcFilesystem._file_type_to_enum(file_metadata["file_type"])], - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), - ), - list_size=10, - offset=0, - ) empty_response = filesystem_pb2.GetFilesResponse(files=[], total_count=0) rpc.send_initial_metadata(()) rpc.terminate(empty_response, (), grpc.StatusCode.OK, "") @@ -604,7 +595,7 @@ def test_update_file_success( upload_request = filesystem_pb2.UploadFilesRequest( files=[ filesystem_pb2.UploadFileData( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, name=file_metadata["name"], file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), content_type=file_metadata["content_type"], @@ -642,7 +633,7 @@ def test_update_file_success( # Create a request object for the mock servicer update_request = filesystem_pb2.UpdateFileRequest( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, file_id=file_id, content=updated_content, file_type=GrpcFilesystem._file_type_to_enum("DOCUMENT"), @@ -739,7 +730,7 @@ def test_delete_files_success( upload_files = [ filesystem_pb2.UploadFileData( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, name=name, file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), content_type=file_metadata["content_type"], @@ -779,9 +770,9 @@ def test_delete_files_success( # Create a request object for the mock servicer delete_request = filesystem_pb2.DeleteFilesRequest( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, filters=filesystem_pb2.FileFilter( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, file_types=[GrpcFilesystem._file_type_to_enum(file_metadata["file_type"])], status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), ), @@ -948,7 +939,7 @@ def test_file_status_handling( upload_request = filesystem_pb2.UploadFilesRequest( files=[ filesystem_pb2.UploadFileData( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, name=file_metadata["name"], file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), content_type=file_metadata["content_type"], @@ -985,7 +976,7 @@ def test_file_status_handling( method_desc = service_desc.methods_by_name["UpdateFile"] _, _, rpc = test_channel.take_unary_unary(method_desc) update_request = filesystem_pb2.UpdateFileRequest( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, file_id=file_id, status=GrpcFilesystem._file_status_to_enum("ACTIVE"), ) @@ -1002,7 +993,7 @@ def test_file_status_handling( method_desc = service_desc.methods_by_name["GetFile"] _, _, rpc = test_channel.take_unary_unary(method_desc) get_request = filesystem_pb2.GetFileRequest( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, file_id=file_id, ) response = mock_servicer.GetFile(get_request, FakeContext()) @@ -1029,10 +1020,8 @@ def test_file_status_handling( ), ) - # Build proto filter manually to avoid context ID conversion - # The mock servicer expects raw context ("setup") not ID ("setup:1") filters_proto = filesystem_pb2.FileFilter( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, file_types=[GrpcFilesystem._file_type_to_enum(file_metadata["file_type"])], status=GrpcFilesystem._file_status_to_enum("ACTIVE"), ) @@ -1040,7 +1029,7 @@ def test_file_status_handling( method_desc = service_desc.methods_by_name["DeleteFiles"] _, _, rpc = test_channel.take_unary_unary(method_desc) delete_request = filesystem_pb2.DeleteFilesRequest( - context=file_metadata["context"], + context=filesystem_pb2.CONTEXT_SETUP, filters=filters_proto, permanent=False, force=False, @@ -1078,3 +1067,68 @@ def test_file_status_handling( # """ # # Add regression tests below as bugs are discovered and fixed. + + +class TestContextScopes: + """Tests that the ContextFile kind maps to the right wire enum.""" + + @pytest.mark.parametrize( + ("context", "wire"), + [ + (ContextFile.MISSIONS, filesystem_pb2.CONTEXT_MISSIONS), + (ContextFile.SETUP, filesystem_pb2.CONTEXT_SETUP), + (ContextFile.USERS, filesystem_pb2.CONTEXT_USERS), + (ContextFile.ORGANIZATIONS, filesystem_pb2.CONTEXT_ORGANIZATIONS), + (ContextFile.UNSPECIFIED, filesystem_pb2.CONTEXT_UNSPECIFIED), + ], + ) + def test_get_files_forwards_context_kind( + self, + context: ContextFile, + wire: "filesystem_pb2.ContextFile", + client: GrpcFilesystem, + test_channel: grpc_testing.Channel, + mock_servicer: MockFilesystemServicer, + ) -> None: + """get_files emits the matching context kind on the request and its filter. + + Covers the cross-owner scopes USERS / ORGANIZATIONS added alongside the + existing MISSIONS / SETUP; the concrete owner id is resolved server-side. + """ + future = client_execution_thread_pool.submit( + asyncio.run, + client.get_files(FileFilter(context=context)), + ) + + method_desc = service_name.methods_by_name["GetFiles"] + _, request, rpc = test_channel.take_unary_unary(method_desc) + + assert request.context == wire + assert request.filters.context == wire + + rpc.send_initial_metadata(()) + rpc.terminate(mock_servicer.GetFiles(request, FakeContext()), (), grpc.StatusCode.OK, "") + + files, _total = future.result(timeout=5.0) + assert isinstance(files, list) + + def test_get_file_forwards_cross_owner_context( + self, + client: GrpcFilesystem, + test_channel: grpc_testing.Channel, + mock_servicer: MockFilesystemServicer, + ) -> None: + """get_file under USERS emits CONTEXT_USERS on the wire.""" + future = client_execution_thread_pool.submit( + asyncio.run, + client.get_file("file_x", context=ContextFile.USERS), + ) + + method_desc = service_name.methods_by_name["GetFile"] + _, request, rpc = test_channel.take_unary_unary(method_desc) + + assert request.context == filesystem_pb2.CONTEXT_USERS + + rpc.send_initial_metadata(()) + rpc.terminate(mock_servicer.GetFile(request, FakeContext()), (), grpc.StatusCode.OK, "") + assert isinstance(future.result(timeout=5.0), FilesystemRecord) diff --git a/tests/services/registry/mock_registry_servicer.py b/tests/services/registry/mock_registry_servicer.py index 8b009a5a..24ca4a8a 100644 --- a/tests/services/registry/mock_registry_servicer.py +++ b/tests/services/registry/mock_registry_servicer.py @@ -21,6 +21,8 @@ def __init__(self) -> None: super().__init__() # module_id -> module data self.registered_modules: dict[str, dict[str, Any]] = {} + # setup_id -> setup data + self.setups: dict[str, dict[str, Any]] = {} def _create_module_descriptor(self, module_data: dict[str, Any]) -> registry_models_pb2.ModuleDescriptor: """Create a ModuleDescriptor from module data. @@ -34,7 +36,7 @@ def _create_module_descriptor(self, module_data: dict[str, Any]) -> registry_mod # Map module type string to proto enum type_mapping = { "archetype": registry_enums_pb2.MODULE_TYPE_ARCHETYPE, - "tool": registry_enums_pb2.MODULE_TYPE_TOOL, + "tool_module": registry_enums_pb2.MODULE_TYPE_TOOL_MODULE, } module_type = type_mapping.get(module_data.get("module_type", ""), registry_enums_pb2.MODULE_TYPE_UNSPECIFIED) @@ -74,13 +76,17 @@ def RegisterModule( logger.warning("Mock: Module '%s' not found for registration", module_id) return registry_requests_pb2.RegisterModuleResponse() - # Update the module info + # Update the module info; a declared module_type overrides the stored one self.registered_modules[module_id].update({ "address": request.address, "port": request.port, "version": request.version, "status": registry_enums_pb2.MODULE_STATUS_ACTIVE, }) + if request.module_type != registry_enums_pb2.MODULE_TYPE_UNSPECIFIED: + self.registered_modules[module_id]["module_type"] = ( + registry_enums_pb2.ModuleType.Name(request.module_type).removeprefix("MODULE_TYPE_").lower() + ) logger.debug("Mock: Module %s registered at %s:%d", module_id, request.address, request.port) return registry_requests_pb2.RegisterModuleResponse( @@ -116,42 +122,72 @@ def Heartbeat( self.registered_modules[module_id]["status"] = registry_enums_pb2.MODULE_STATUS_ACTIVE return registry_requests_pb2.HeartbeatResponse(status=registry_enums_pb2.MODULE_STATUS_ACTIVE) - def DiscoverModules( + def _create_module_summary(self, module_data: dict[str, Any]) -> registry_models_pb2.ModuleSummary: + """Create a ModuleSummary from module data. + + Args: + module_data: The module data dictionary. + + Returns: + ModuleSummary protobuf message. + """ + type_mapping = { + "archetype": registry_enums_pb2.MODULE_TYPE_ARCHETYPE, + "tool_module": registry_enums_pb2.MODULE_TYPE_TOOL_MODULE, + } + return registry_models_pb2.ModuleSummary( + id=module_data["module_id"], + name=module_data.get("name", module_data["module_id"]), + module_type=type_mapping.get(module_data.get("module_type", ""), registry_enums_pb2.MODULE_TYPE_UNSPECIFIED), + version=module_data.get("version", ""), + status=module_data.get("status", registry_enums_pb2.MODULE_STATUS_READY), + visibility=module_data.get("visibility", registry_enums_pb2.VISIBILITY_PRIVATE), + organization_id=module_data.get("organization_id", ""), + documentation=module_data.get("documentation", ""), + ) + + def SearchModules( self, - request: registry_requests_pb2.DiscoverModulesRequest, + request: registry_requests_pb2.SearchModulesRequest, context: grpc.ServicerContext, - ) -> registry_requests_pb2.DiscoverModulesResponse: - """Discover modules based on search criteria. + ) -> registry_requests_pb2.SearchModulesResponse: + """Search modules based on search criteria. Args: - request: The discover modules request. + request: The search modules request. context: The gRPC context. Returns: - DiscoverModulesResponse with matching modules. + SearchModulesResponse with matching module summaries and total count. """ - logger.debug("Mock: Discovering modules with query '%s'", request.query) + logger.debug("Mock: Searching modules with query '%s'", request.query) results = list(self.registered_modules.values()) - # Filter by query (name match) - if request.query: - results = [m for m in results if request.query in m.get("name", m["module_id"])] - - # Filter by module types if specified + if request.module_ids: + results = [m for m in results if m["module_id"] in request.module_ids] if request.module_types: - type_strings = [] - for mt in request.module_types: - if mt == registry_enums_pb2.MODULE_TYPE_ARCHETYPE: - type_strings.append("archetype") - elif mt == registry_enums_pb2.MODULE_TYPE_TOOL: - type_strings.append("tool") - if type_strings: - results = [m for m in results if m.get("module_type", "") in type_strings] - - logger.debug("Mock: Found %d matching modules", len(results)) - return registry_requests_pb2.DiscoverModulesResponse( - modules=[self._create_module_descriptor(m) for m in results] + type_strings = [ + registry_enums_pb2.ModuleType.Name(mt).removeprefix("MODULE_TYPE_").lower() + for mt in request.module_types + ] + results = [m for m in results if m.get("module_type", "") in type_strings] + if request.query: + needle = request.query.lower() + results = [ + m + for m in results + if needle in m.get("name", m["module_id"]).lower() or needle in m.get("documentation", "").lower() + ] + + total = len(results) + limit = request.limit or 20 + results = results[request.offset : request.offset + limit] + + logger.debug("Mock: Found %d matching modules (returning %d)", total, len(results)) + return registry_requests_pb2.SearchModulesResponse( + modules=[self._create_module_summary(m) for m in results], + total=total, ) def GetModule( @@ -180,23 +216,80 @@ def GetModule( return self._create_module_descriptor(self.registered_modules[request.module_id]) - def DiscoverSetups( + def _create_setup_summary(self, setup_data: dict[str, Any]) -> registry_models_pb2.SetupSummary: + """Create a SetupSummary from setup data. + + Args: + setup_data: The setup data dictionary. + + Returns: + SetupSummary protobuf message. + """ + type_mapping = { + "archetype": registry_enums_pb2.MODULE_TYPE_ARCHETYPE, + "tool_module": registry_enums_pb2.MODULE_TYPE_TOOL_MODULE, + } + return registry_models_pb2.SetupSummary( + id=setup_data["setup_id"], + name=setup_data.get("name", setup_data["setup_id"]), + documentation=setup_data.get("documentation", ""), + status=setup_data.get("status", registry_enums_pb2.SETUP_STATUS_READY), + visibility=setup_data.get("visibility", registry_enums_pb2.VISIBILITY_PRIVATE), + organization_id=setup_data.get("organization_id", ""), + module_id=setup_data.get("module_id", ""), + module_name=setup_data.get("module_name", ""), + module_type=type_mapping.get(setup_data.get("module_type", ""), registry_enums_pb2.MODULE_TYPE_UNSPECIFIED), + setup_version_id=setup_data.get("setup_version_id", ""), + setup_version=setup_data.get("setup_version", ""), + ) + + def SearchSetups( self, - request: registry_requests_pb2.DiscoverSetupsRequest, + request: registry_requests_pb2.SearchSetupsRequest, context: grpc.ServicerContext, - ) -> registry_requests_pb2.DiscoverSetupsResponse: - """Discover setups based on search criteria. + ) -> registry_requests_pb2.SearchSetupsResponse: + """Search setups based on search criteria. Args: - request: The discover setups request. + request: The search setups request. context: The gRPC context. Returns: - DiscoverSetupsResponse with matching setups. + SearchSetupsResponse with matching setup summaries and total count. """ - logger.debug("Mock: Discovering setups with query '%s'", request.query) - # Not implemented in mock - return empty - return registry_requests_pb2.DiscoverSetupsResponse() + logger.debug("Mock: Searching setups with query '%s'", request.query) + + results = list(self.setups.values()) + + if request.setup_ids: + results = [s for s in results if s["setup_id"] in request.setup_ids] + if request.module_ids: + results = [s for s in results if s.get("module_id", "") in request.module_ids] + if request.module_types: + type_strings = [ + registry_enums_pb2.ModuleType.Name(mt).removeprefix("MODULE_TYPE_").lower() + for mt in request.module_types + ] + results = [s for s in results if s.get("module_type", "") in type_strings] + if request.statuses: + results = [s for s in results if s.get("status", registry_enums_pb2.SETUP_STATUS_READY) in request.statuses] + if request.query: + needle = request.query.lower() + results = [ + s + for s in results + if needle in s.get("name", s["setup_id"]).lower() or needle in s.get("documentation", "").lower() + ] + + total = len(results) + limit = request.limit or 20 + results = results[request.offset : request.offset + limit] + + logger.debug("Mock: Found %d matching setups (returning %d)", total, len(results)) + return registry_requests_pb2.SearchSetupsResponse( + setups=[self._create_setup_summary(s) for s in results], + total=total, + ) def GetSetup( self, diff --git a/tests/services/registry/test_default_registry.py b/tests/services/registry/test_default_registry.py new file mode 100644 index 00000000..95956497 --- /dev/null +++ b/tests/services/registry/test_default_registry.py @@ -0,0 +1,226 @@ +"""Tests for DefaultRegistry module_type handling and module class registry markers.""" + +import pytest +from pydantic import ValidationError + +from digitalkin.models.services.registry import ( + ModuleInfo, + RegistryModuleType, + RegistrySetupStatus, + SetupInfo, +) +from digitalkin.modules import ArchetypeModule, ToolModule +from digitalkin.modules._base_module import BaseModule +from digitalkin.services.registry import DefaultRegistry + + +class TestDefaultRegistryModuleType: + """Tests for module_type storage in DefaultRegistry.register().""" + + async def test_register_stores_declared_type(self) -> None: + """Registering with an explicit type stores it.""" + registry = DefaultRegistry("", "", "") + result = await registry.register( + module_id="modules:tool1", + address="localhost", + port=50051, + version="1.0.0", + module_type=RegistryModuleType.TOOL_MODULE, + ) + assert result is not None + assert result.module_type == RegistryModuleType.TOOL_MODULE + + async def test_register_unspecified_preserves_existing_type(self) -> None: + """Re-registering with UNSPECIFIED keeps the previously declared type.""" + registry = DefaultRegistry("", "", "") + await registry.register( + module_id="modules:kin1", + address="localhost", + port=50051, + version="1.0.0", + module_type=RegistryModuleType.ARCHETYPE, + ) + result = await registry.register( + module_id="modules:kin1", + address="localhost", + port=50052, + version="1.0.1", + ) + assert result is not None + assert result.module_type == RegistryModuleType.ARCHETYPE + assert result.port == 50052 + + @pytest.mark.parametrize( + ("view", "expected_type"), + [ + ("search_tools", RegistryModuleType.TOOL_MODULE), + ("search_kins", RegistryModuleType.ARCHETYPE), + ("search_services", RegistryModuleType.SERVICE), + ], + ) + async def test_typed_views_filter_by_type(self, view: str, expected_type: RegistryModuleType) -> None: + """search_tools/search_kins/search_services return only modules of the matching type.""" + registry = DefaultRegistry("", "", "") + await registry.register( + module_id="modules:tool1", + address="localhost", + port=50051, + version="1.0.0", + module_type=RegistryModuleType.TOOL_MODULE, + ) + await registry.register( + module_id="modules:kin1", + address="localhost", + port=50052, + version="1.0.0", + module_type=RegistryModuleType.ARCHETYPE, + ) + await registry.register( + module_id="modules:svc1", + address="localhost", + port=50053, + version="1.0.0", + module_type=RegistryModuleType.SERVICE, + ) + views = { + "search_tools": registry.search_tools, + "search_kins": registry.search_kins, + "search_services": registry.search_services, + } + results = await views[view]() + assert len(results) == 1 + assert results[0].module_type == expected_type + + +class TestDefaultRegistrySetups: + """Tests for the in-memory setup store and search_setups().""" + + def _seed(self, registry: DefaultRegistry) -> None: + """Store one tool setup and one archetype setup.""" + registry.add_setup( + SetupInfo( + setup_id="setups:duda", + name="Duda Builder", + documentation="Builds websites on the Duda platform", + status=RegistrySetupStatus.READY, + module_id="modules:duda", + module_name="tool-duda", + module_type=RegistryModuleType.TOOL_MODULE, + ) + ) + registry.add_setup( + SetupInfo( + setup_id="setups:isaac", + name="Isaac", + documentation="Multi-agent orchestration kin", + status=RegistrySetupStatus.DRAFT, + module_id="modules:isaac", + module_name="archetype-isaac", + module_type=RegistryModuleType.ARCHETYPE, + ) + ) + + async def test_add_and_get_setup_roundtrip(self) -> None: + """add_setup stores and get_setup retrieves; missing id returns None.""" + registry = DefaultRegistry("", "", "") + self._seed(registry) + setup = await registry.get_setup("setups:duda") + assert setup is not None + assert setup.name == "Duda Builder" + assert await registry.get_setup("setups:unknown") is None + + async def test_search_setups_query_matches_name_and_documentation(self) -> None: + """Query matches case-insensitively on name and documentation.""" + registry = DefaultRegistry("", "", "") + self._seed(registry) + by_name = await registry.search_setups(query="DUDA") + assert [s.setup_id for s in by_name] == ["setups:duda"] + by_doc = await registry.search_setups(query="orchestration") + assert [s.setup_id for s in by_doc] == ["setups:isaac"] + + async def test_search_setups_facet_filters(self) -> None: + """module_types and statuses filters narrow results.""" + registry = DefaultRegistry("", "", "") + self._seed(registry) + tools = await registry.search_setups(module_types=[RegistryModuleType.TOOL_MODULE]) + assert [s.setup_id for s in tools] == ["setups:duda"] + ready = await registry.search_setups(statuses=[RegistrySetupStatus.READY]) + assert [s.setup_id for s in ready] == ["setups:duda"] + + async def test_search_setups_pagination(self) -> None: + """offset/limit slice the result list.""" + registry = DefaultRegistry("", "", "") + self._seed(registry) + page1 = await registry.search_setups(limit=1) + page2 = await registry.search_setups(limit=1, offset=1) + assert len(page1) == len(page2) == 1 + assert page1[0].setup_id != page2[0].setup_id + + async def test_search_setups_no_match(self) -> None: + """Unknown query returns an empty list.""" + registry = DefaultRegistry("", "", "") + self._seed(registry) + assert await registry.search_setups(query="nothing") == [] + + async def test_search_setups_returns_config_free_summary(self) -> None: + """search_setups yields SetupSummary — a stored setup's config can never be serialized.""" + registry = DefaultRegistry("", "", "") + self._seed(registry) + results = await registry.search_setups() + assert results + assert all("config" not in type(s).model_fields for s in results) + + +class TestLegacyModuleTypeAliases: + """Legacy 'tool'/'kin' vocabulary from older SDK releases parses into the proto-aligned enum.""" + + def test_tool_normalized(self) -> None: + assert ModuleInfo(module_type="tool").module_type == RegistryModuleType.TOOL_MODULE + + def test_kin_normalized(self) -> None: + assert ModuleInfo(module_type="kin").module_type == RegistryModuleType.ARCHETYPE + + def test_canonical_values_untouched(self) -> None: + assert ModuleInfo(module_type="tool_module").module_type == RegistryModuleType.TOOL_MODULE + assert ModuleInfo(module_type=RegistryModuleType.SERVICE).module_type == RegistryModuleType.SERVICE + + def test_unknown_value_still_fails(self) -> None: + with pytest.raises(ValidationError): + ModuleInfo(module_type="bogus") + + +class TestGetServiceSetup: + """Tests for RegistryStrategy.get_service_setup (chat-discovered service setup).""" + + async def test_returns_setup_version_content(self) -> None: + """The setup's config JSON is returned as-is for a discovered id.""" + registry = DefaultRegistry("", "", "") + registry.add_setup( + SetupInfo( + setup_id="setups:service", + name="Nikita Branding Service", + status=RegistrySetupStatus.READY, + config={"llm": {"provider": "litellm"}, "flags": ["a"]}, + ) + ) + assert await registry.get_service_setup("setups:service") == {"llm": {"provider": "litellm"}, "flags": ["a"]} + + async def test_missing_setup_returns_none(self) -> None: + """An unknown setup id resolves to None, not an exception.""" + assert await DefaultRegistry("", "", "").get_service_setup("setups:absent") is None + + async def test_setup_without_content_returns_none(self) -> None: + """A setup with no config JSON resolves to None.""" + registry = DefaultRegistry("", "", "") + registry.add_setup(SetupInfo(setup_id="setups:empty", name="Empty", status=RegistrySetupStatus.READY)) + assert await registry.get_service_setup("setups:empty") is None + + +class TestRegistryTypeMarkers: + """Tests for the registry_type ClassVar on module base classes.""" + + def test_module_class_markers(self) -> None: + """Module base classes declare their registry type.""" + assert BaseModule.registry_type == RegistryModuleType.UNSPECIFIED + assert ToolModule.registry_type == RegistryModuleType.TOOL_MODULE + assert ArchetypeModule.registry_type == RegistryModuleType.ARCHETYPE diff --git a/tests/services/registry/test_grpc_registry.py b/tests/services/registry/test_grpc_registry.py index 2e4269e2..2fb107ee 100644 --- a/tests/services/registry/test_grpc_registry.py +++ b/tests/services/registry/test_grpc_registry.py @@ -22,7 +22,12 @@ ) from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.models.services.registry import RegistryModuleStatus, RegistryModuleType +from digitalkin.models.services.registry import ( + RegistryModuleStatus, + RegistryModuleType, + RegistrySetupStatus, + RegistryVisibility, +) from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.registry.exceptions import ( RegistryServiceError, @@ -109,7 +114,7 @@ def client( registry_client = GrpcRegistry(MISSION_ID, SETUP_ID, SETUP_VERSION_ID, dummy_client_config) registry_client.stub = AsyncStubWrapper(registry_service_pb2_grpc.RegistryServiceStub(test_channel)) - async def _test_exec_grpc_query(self, query_endpoint, request): + async def _test_exec_grpc_query(self, query_endpoint, request, timeout=None, metadata=None): response = getattr(self.stub, query_endpoint)(request) return await response if asyncio.iscoroutine(response) else response @@ -142,7 +147,7 @@ def test_discover_by_id_success( # Pre-register a module mock_servicer.registered_modules[module_id] = { "module_id": module_id, - "module_type": "tool", + "module_type": "tool_module", "name": "TestModule", "address": "localhost", "port": 50051, @@ -176,7 +181,7 @@ def test_discover_by_id_success( # Verify result assert result is not None assert result.module_id == module_id - assert result.module_type == RegistryModuleType.TOOL + assert result.module_type == RegistryModuleType.TOOL_MODULE assert result.address == "localhost" assert result.port == 50051 assert result.module_name == "TestModule" @@ -230,7 +235,7 @@ def test_search_by_name( # Pre-register modules mock_servicer.registered_modules["mod1"] = { "module_id": "mod1", - "module_type": "tool", + "module_type": "tool_module", "name": "SearchableModule", "address": "localhost", "port": 50051, @@ -248,7 +253,7 @@ def test_search_by_name( } method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ - "DiscoverModules" + "SearchModules" ] future = thread_pool.submit(asyncio.run, client.search(name="Searchable")) @@ -256,7 +261,7 @@ def test_search_by_name( _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.DiscoverModules(request, context) + response = mock_servicer.SearchModules(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -279,7 +284,7 @@ def test_search_by_type( """Test searching modules by type.""" mock_servicer.registered_modules["mod1"] = { "module_id": "mod1", - "module_type": "tool", + "module_type": "tool_module", "name": "Tool1", "address": "localhost", "port": 50051, @@ -297,15 +302,15 @@ def test_search_by_type( } method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ - "DiscoverModules" + "SearchModules" ] - future = thread_pool.submit(asyncio.run, client.search(module_type="tool")) + future = thread_pool.submit(asyncio.run, client.search(module_type="tool_module")) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.DiscoverModules(request, context) + response = mock_servicer.SearchModules(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -313,7 +318,112 @@ def test_search_by_type( results = future.result(timeout=1.0) assert len(results) == 1 - assert results[0].module_type == RegistryModuleType.TOOL + assert results[0].module_type == RegistryModuleType.TOOL_MODULE + + @pytest.mark.grpc + @pytest.mark.integration + def test_search_returns_trimmed_summaries( + self, + client: GrpcRegistry, + test_channel: grpc_testing.Channel, + mock_servicer: MockRegistryServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test search sends limit on the wire and never populates address/port.""" + mock_servicer.registered_modules["mod1"] = { + "module_id": "mod1", + "module_type": "tool_module", + "name": "Tool1", + "address": "localhost", + "port": 50051, + "version": "1.0.0", + "status": registry_enums_pb2.MODULE_STATUS_READY, + } + + method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ + "SearchModules" + ] + + future = thread_pool.submit(asyncio.run, client.search(name="Tool", limit=5)) + + _, request, rpc = test_channel.take_unary_unary(method_desc) + + assert request.limit == 5 + + context = FakeContext() + response = mock_servicer.SearchModules(request, context) + + rpc.send_initial_metadata(()) + rpc.terminate(response, (), grpc.StatusCode.OK, "") + + results = future.result(timeout=1.0) + + assert len(results) == 1 + # ModuleSummary is trimmed: network location never crosses the search surface + assert results[0].address == "" + assert results[0].port == 0 + assert results[0].status == RegistryModuleStatus.READY + assert results[0].version == "1.0.0" + + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.parametrize( + ("view", "expected_type", "expected_id"), + [ + ("search_tools", RegistryModuleType.TOOL_MODULE, "mod1"), + ("search_kins", RegistryModuleType.ARCHETYPE, "mod2"), + ], + ) + def test_typed_views( + self, + client: GrpcRegistry, + test_channel: grpc_testing.Channel, + mock_servicer: MockRegistryServicer, + thread_pool: futures.ThreadPoolExecutor, + view: str, + expected_type: RegistryModuleType, + expected_id: str, + ) -> None: + """Test search_tools/search_kins return only the matching module type.""" + mock_servicer.registered_modules["mod1"] = { + "module_id": "mod1", + "module_type": "tool_module", + "name": "Tool1", + "address": "localhost", + "port": 50051, + "version": "1.0.0", + "status": registry_enums_pb2.MODULE_STATUS_READY, + } + mock_servicer.registered_modules["mod2"] = { + "module_id": "mod2", + "module_type": "archetype", + "name": "Archetype1", + "address": "localhost", + "port": 50052, + "version": "1.0.0", + "status": registry_enums_pb2.MODULE_STATUS_READY, + } + + method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ + "SearchModules" + ] + + search_view = client.search_tools if view == "search_tools" else client.search_kins + future = thread_pool.submit(asyncio.run, search_view()) + + _, request, rpc = test_channel.take_unary_unary(method_desc) + + context = FakeContext() + response = mock_servicer.SearchModules(request, context) + + rpc.send_initial_metadata(()) + rpc.terminate(response, (), grpc.StatusCode.OK, "") + + results = future.result(timeout=1.0) + + assert len(results) == 1 + assert results[0].module_id == expected_id + assert results[0].module_type == expected_type @pytest.mark.grpc @pytest.mark.integration @@ -326,7 +436,7 @@ def test_search_no_results( ) -> None: """Test search with no matching results.""" method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ - "DiscoverModules" + "SearchModules" ] future = thread_pool.submit(asyncio.run, client.search(name="NonExistent")) @@ -334,7 +444,7 @@ def test_search_no_results( _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.DiscoverModules(request, context) + response = mock_servicer.SearchModules(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -363,7 +473,7 @@ def test_register_success( # Pre-register module (new proto requires module to exist) mock_servicer.registered_modules[module_id] = { "module_id": module_id, - "module_type": "tool", + "module_type": "tool_module", "name": "ExistingModule", "address": "old-host", "port": 50050, @@ -405,6 +515,59 @@ def test_register_success( assert result.address == "localhost" assert result.port == 50053 + @pytest.mark.grpc + @pytest.mark.integration + def test_register_declares_module_type( + self, + client: GrpcRegistry, + test_channel: grpc_testing.Channel, + mock_servicer: MockRegistryServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test registration sends the declared module type on the wire.""" + module_id = "existing_module" + + # Pre-register module with no type — registration declares it + mock_servicer.registered_modules[module_id] = { + "module_id": module_id, + "module_type": "", + "name": "ExistingModule", + "address": "old-host", + "port": 50050, + "version": "0.9.0", + "status": registry_enums_pb2.MODULE_STATUS_READY, + } + + method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ + "RegisterModule" + ] + + future = thread_pool.submit( + asyncio.run, + client.register( + module_id=module_id, + address="localhost", + port=50053, + version="1.0.0", + module_type=RegistryModuleType.TOOL_MODULE, + ), + ) + + _, request, rpc = test_channel.take_unary_unary(method_desc) + + assert request.module_type == registry_enums_pb2.MODULE_TYPE_TOOL_MODULE + + context = FakeContext() + response = mock_servicer.RegisterModule(request, context) + + rpc.send_initial_metadata(()) + rpc.terminate(response, (), grpc.StatusCode.OK, "") + + result = future.result(timeout=1.0) + + assert result is not None + assert result.module_type == RegistryModuleType.TOOL_MODULE + @pytest.mark.grpc @pytest.mark.integration @pytest.mark.edge_case @@ -464,7 +627,7 @@ def test_get_status_success( mock_servicer.registered_modules[module_id] = { "module_id": module_id, - "module_type": "tool", + "module_type": "tool_module", "name": "TestModule", "address": "localhost", "port": 50051, @@ -508,7 +671,7 @@ def test_heartbeat_success( mock_servicer.registered_modules[module_id] = { "module_id": module_id, - "module_type": "tool", + "module_type": "tool_module", "name": "TestModule", "address": "localhost", "port": 50051, @@ -564,3 +727,138 @@ def test_heartbeat_not_found( # Returns UNSPECIFIED status when module not found assert result == RegistryModuleStatus.UNSPECIFIED + + +class TestSearchSetups: + """Tests for the search_setups() method.""" + + def _seed_setups(self, mock_servicer: MockRegistryServicer) -> None: + """Seed the mock servicer with two setups.""" + mock_servicer.setups["setups:duda"] = { + "setup_id": "setups:duda", + "name": "Duda Builder", + "documentation": "Builds websites on the Duda platform", + "status": registry_enums_pb2.SETUP_STATUS_READY, + "visibility": registry_enums_pb2.VISIBILITY_PUBLIC, + "organization_id": "organizations:dk", + "module_id": "modules:duda", + "module_name": "tool-duda", + "module_type": "tool_module", + "setup_version_id": "setup_versions:v1", + "setup_version": "1.0.0", + } + mock_servicer.setups["setups:isaac"] = { + "setup_id": "setups:isaac", + "name": "Isaac", + "documentation": "Multi-agent orchestration kin", + "status": registry_enums_pb2.SETUP_STATUS_DRAFT, + "visibility": registry_enums_pb2.VISIBILITY_PRIVATE, + "organization_id": "organizations:dk", + "module_id": "modules:isaac", + "module_name": "archetype-isaac", + "module_type": "archetype", + "setup_version_id": "setup_versions:v2", + "setup_version": "2.0.0", + } + + def _run_search( + self, + client: GrpcRegistry, + test_channel: grpc_testing.Channel, + mock_servicer: MockRegistryServicer, + thread_pool: futures.ThreadPoolExecutor, + **kwargs: object, + ) -> tuple[object, list]: + """Run a search_setups call through the test channel, returning (request, results).""" + method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ + "SearchSetups" + ] + future = thread_pool.submit(asyncio.run, client.search_setups(**kwargs)) + _, request, rpc = test_channel.take_unary_unary(method_desc) + response = mock_servicer.SearchSetups(request, FakeContext()) + rpc.send_initial_metadata(()) + rpc.terminate(response, (), grpc.StatusCode.OK, "") + return request, future.result(timeout=1.0) + + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.smoke + def test_search_setups_maps_summary( + self, + client: GrpcRegistry, + test_channel: grpc_testing.Channel, + mock_servicer: MockRegistryServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Proto SetupSummary maps into the search-safe SetupSummary with enums and no config field.""" + self._seed_setups(mock_servicer) + + _, results = self._run_search(client, test_channel, mock_servicer, thread_pool, query="duda") + + assert len(results) == 1 + setup = results[0] + assert setup.setup_id == "setups:duda" + assert setup.name == "Duda Builder" + assert setup.status == RegistrySetupStatus.READY + assert setup.visibility == RegistryVisibility.PUBLIC + assert setup.module_id == "modules:duda" + assert setup.module_name == "tool-duda" + assert setup.module_type == RegistryModuleType.TOOL_MODULE + assert setup.setup_version == "1.0.0" + assert "config" not in type(setup).model_fields + + @pytest.mark.grpc + @pytest.mark.integration + def test_search_setups_query_matches_documentation( + self, + client: GrpcRegistry, + test_channel: grpc_testing.Channel, + mock_servicer: MockRegistryServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test the query filter matches documentation, not just name.""" + self._seed_setups(mock_servicer) + + _, results = self._run_search(client, test_channel, mock_servicer, thread_pool, query="orchestration") + + assert len(results) == 1 + assert results[0].setup_id == "setups:isaac" + + @pytest.mark.grpc + @pytest.mark.integration + def test_search_setups_statuses_filter_on_wire( + self, + client: GrpcRegistry, + test_channel: grpc_testing.Channel, + mock_servicer: MockRegistryServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test the statuses filter is sent on the wire and applied.""" + self._seed_setups(mock_servicer) + + request, results = self._run_search( + client, + test_channel, + mock_servicer, + thread_pool, + statuses=[RegistrySetupStatus.READY], + ) + + assert list(request.statuses) == [registry_enums_pb2.SETUP_STATUS_READY] + assert len(results) == 1 + assert results[0].setup_id == "setups:duda" + + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.edge_case + def test_search_setups_no_results( + self, + client: GrpcRegistry, + test_channel: grpc_testing.Channel, + mock_servicer: MockRegistryServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test search with no matches returns an empty list.""" + _, results = self._run_search(client, test_channel, mock_servicer, thread_pool, query="nothing") + + assert results == [] diff --git a/tests/services/registry/test_registry_hardening.py b/tests/services/registry/test_registry_hardening.py new file mode 100644 index 00000000..250941b6 --- /dev/null +++ b/tests/services/registry/test_registry_hardening.py @@ -0,0 +1,102 @@ +"""Registry hardening: enum encode/decode symmetry + registry-scoped settings.""" + +from enum import Enum + +import pytest +from agentic_mesh_protocol.registry.v1 import registry_enums_pb2 + +from digitalkin.models.services.registry import ( + RegistryModuleType, + RegistrySetupStatus, + RegistryVisibility, +) +from digitalkin.models.settings.registry import get_registry_settings +from digitalkin.services.registry.grpc_registry import GrpcRegistry + +_ENUM_CASES = [ + (registry_enums_pb2.ModuleType, "MODULE_TYPE", RegistryModuleType), + (registry_enums_pb2.SetupStatus, "SETUP_STATUS", RegistrySetupStatus), + (registry_enums_pb2.Visibility, "VISIBILITY", RegistryVisibility), +] + + +@pytest.mark.parametrize(("proto_enum", "prefix", "py_enum"), _ENUM_CASES) +def test_every_member_encodes_to_valid_proto_name(proto_enum: object, prefix: str, py_enum: type[Enum]) -> None: + """Every Python registry enum member maps to a proto member the server accepts. + + Regression for silent Python/proto enum-name drift, which would otherwise produce an + unrecognized filter string and fail the invocable-only guard open. + """ + for member in py_enum: + name = GrpcRegistry._encode_enum(proto_enum, prefix, member) + assert name == f"{prefix}_{member.name}" + assert proto_enum.Name(proto_enum.Value(name)) == name + + +def test_unknown_member_fails_closed() -> None: + """A member with no proto counterpart raises instead of sending a bogus filter.""" + + class _Drifted(Enum): + NONEXISTENT = "nonexistent" + + with pytest.raises(ValueError, match="NONEXISTENT"): + GrpcRegistry._encode_enum(registry_enums_pb2.SetupStatus, "SETUP_STATUS", _Drifted.NONEXISTENT) + + +def test_registry_settings_default() -> None: + """The agent-facing search deadline defaults below the global gRPC 30s.""" + get_registry_settings.cache_clear() + try: + assert get_registry_settings().search_timeout_s == pytest.approx(10.0) + finally: + get_registry_settings.cache_clear() + + +def test_registry_settings_env_override(monkeypatch: pytest.MonkeyPatch) -> None: + """search_timeout_s is tunable via DIGITALKIN_REGISTRY_SEARCH_TIMEOUT_S.""" + monkeypatch.setenv("DIGITALKIN_REGISTRY_SEARCH_TIMEOUT_S", "3.5") + get_registry_settings.cache_clear() + try: + assert get_registry_settings().search_timeout_s == pytest.approx(3.5) + finally: + get_registry_settings.cache_clear() + + +async def test_search_setups_forwards_tuned_deadline() -> None: + """search_setups forwards the registry-scoped deadline to exec_grpc_query.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from digitalkin.models.grpc_servers.models import ClientConfig + from digitalkin.models.settings.utils.channel import SecurityMode + + get_registry_settings.cache_clear() + client = GrpcRegistry( + "missions:m", "setups:s", "v1", ClientConfig(host="127.0.0.1", port=1, security=SecurityMode.INSECURE) + ) + client.exec_grpc_query = AsyncMock(return_value=SimpleNamespace(setups=[])) + assert await client.search_setups(query="x") == [] + assert client.exec_grpc_query.await_args.kwargs["timeout"] == pytest.approx(10.0) + get_registry_settings.cache_clear() + + +async def test_register_forwards_documentation_to_request() -> None: + """register() attaches documentation to the RegisterModuleRequest for index search.""" + import contextlib + from unittest.mock import AsyncMock + + from digitalkin.models.grpc_servers.models import ClientConfig + from digitalkin.models.services.registry import RegistryModuleType + from digitalkin.models.settings.utils.channel import SecurityMode + + client = GrpcRegistry( + "missions:m", "setups:s", "v1", ClientConfig(host="127.0.0.1", port=1, security=SecurityMode.INSECURE) + ) + client.exec_grpc_query = AsyncMock(return_value=None) + # register() parses the (mocked) response afterward and raises; the request is already captured. + with contextlib.suppress(Exception): + await client.register( + "modules:x", "h", 1, "1.0.0", RegistryModuleType.TOOL_MODULE, documentation="indexed docs" + ) + request = client.exec_grpc_query.await_args.args[1] + assert request.documentation == "indexed docs" diff --git a/tests/services/setup/mock_setup_servicer.py b/tests/services/setup/mock_setup_servicer.py index 22a87177..3ac34fb9 100644 --- a/tests/services/setup/mock_setup_servicer.py +++ b/tests/services/setup/mock_setup_servicer.py @@ -1,4 +1,4 @@ -"""Test file for Module setup Servicer from the client side.""" +"""Mock SetupService servicer implementing the 5-RPC protocol (client-side tests).""" import datetime import secrets @@ -9,272 +9,117 @@ setup_pb2, setup_service_pb2_grpc, ) -from google.protobuf import json_format -from pydantic import ValidationError from digitalkin.logger import logger -from digitalkin.services.setup.setup_strategy import SetupData, SetupVersionData class MockSetupServicer(setup_service_pb2_grpc.SetupServiceServicer): - """Implementation of the MockSetupServicer.""" + """In-memory SetupService double. + + Owner/organisation/module are "derived from the request context" the way the + real server does — here hardcoded to ``ctx-*`` values so tests can assert the + client never sends them. + """ alphabet = string.ascii_letters + string.digits - setups: dict[str, SetupData] - setup_versions: dict[str, dict[str, SetupVersionData]] + setups: dict[str, setup_pb2.Setup] def _generate_id(self) -> str: return "".join(secrets.choice(self.alphabet) for _ in range(16)) def __init__(self) -> None: - """Initialize the setup servicer with an empty setups.""" + """Initialize the setup servicer with an empty store.""" super().__init__() self.setups = {} - self.setup_versions = {} + + @staticmethod + def _sibling_response_pair(setup: setup_pb2.Setup) -> tuple[setup_pb2.Setup, setup_pb2.SetupVersion]: + """Split a stored setup into (setup without embedded version, sibling version). + + Exercises the client's fallback merge path (response-level ``setup_version``). + """ + bare = setup_pb2.Setup() + bare.CopyFrom(setup) + version = setup_pb2.SetupVersion() + version.CopyFrom(setup.current_setup_version) + bare.ClearField("current_setup_version") + return bare, version def CreateSetup( self, request: setup_pb2.CreateSetupRequest, context: grpc.ServicerContext ) -> setup_pb2.CreateSetupResponse: - try: - setup_data_version = SetupVersionData( - id=request.current_setup_version.id, - setup_id=request.current_setup_version.setup_id, - version=request.current_setup_version.version, - creation_date=request.current_setup_version.creation_date.ToDatetime() or datetime.datetime.now(), # noqa: DTZ005 - content=dict(request.current_setup_version.content), - ) - setup_data = SetupData( - id=self._generate_id(), - name=request.name, - organisation_id=request.organisation_id, - module_id=request.module_id, - owner_id=request.owner_id, - current_setup_version=setup_data_version, - ) - except ValidationError: - msg = "Validation failed for model SetupData" - logger.exception(msg) + if not request.name: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(msg) + context.set_details("name is required") return setup_pb2.CreateSetupResponse(success=False) - self.setups[setup_data.id] = setup_data - logger.debug("CREATE SETUP DATA %s:%s succesfull", setup_data.id, setup_data) - return setup_pb2.CreateSetupResponse(success=True) + setup_id = self._generate_id() + setup = setup_pb2.Setup( + id=setup_id, + name=request.name, + organisation_id="ctx-org", + owner_id="ctx-owner", + module_id="ctx-module", + status=setup_pb2.SetupStatus.READY, + visibility=setup_pb2.Visibility.VISIBILITY_PRIVATE, + current_setup_version=setup_pb2.SetupVersion( + id=self._generate_id(), + setup_id=setup_id, + version="1.0.0", + content=request.content, + creation_date=datetime.datetime.now(datetime.timezone.utc), + ), + ) + self.setups[setup_id] = setup + logger.debug("CREATE SETUP %s successful", setup_id) + bare, version = self._sibling_response_pair(setup) + return setup_pb2.CreateSetupResponse(success=True, setup=bare, setup_version=version) def GetSetup(self, request: setup_pb2.GetSetupRequest, context: grpc.ServicerContext) -> setup_pb2.GetSetupResponse: - logger.debug("GET SETUP setup_id = %s.", request.setup_id) - if request.setup_id not in self.setups: + setup = self.setups.get(request.setup_id) + if setup is None: msg = f"GET SETUP setup_id = {request.setup_id} | setup_id DOESN'T EXIST" logger.warning(msg) context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(msg) return setup_pb2.GetSetupResponse() - return setup_pb2.GetSetupResponse(setup=setup_pb2.Setup(**self.setups[request.setup_id].model_dump())) + # Embedded current_setup_version populated: exercises the client's preferred path. + return setup_pb2.GetSetupResponse(setup=setup, setup_version=setup.current_setup_version) def UpdateSetup( self, request: setup_pb2.UpdateSetupRequest, context: grpc.ServicerContext ) -> setup_pb2.UpdateSetupResponse: - if request.setup_id not in self.setups: - msg = f"GET setup_id = {request.setup_id} | setup_id DOESN'T EXIST" - logger.warning(msg) + setup = self.setups.get(request.setup_id) + if setup is None: context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) + context.set_details(f"setup_id = {request.setup_id} DOESN'T EXIST") return setup_pb2.UpdateSetupResponse(success=False) - - # Update only the fields that were explicitly set - # For string fields, check if they're non-empty (proto3 default is empty string) - if request.name: - self.setups[request.setup_id].name = request.name - if request.owner_id: - self.setups[request.setup_id].owner_id = request.owner_id - # For message fields, use HasField() - if request.HasField("current_setup_version"): - # Convert protobuf message to dict first, then validate - setup_version_dict = { - "id": request.current_setup_version.id, - "setup_id": request.current_setup_version.setup_id, - "version": request.current_setup_version.version, - "creation_date": request.current_setup_version.creation_date.ToDatetime() - if request.current_setup_version.HasField("creation_date") - else datetime.datetime.now(), # noqa: DTZ005 - "content": dict(request.current_setup_version.content), - } - self.setups[request.setup_id].current_setup_version = SetupVersionData.model_validate(setup_version_dict) - logger.debug("UPDATE SETUP DATA %s succesfull", request.setup_id) - return setup_pb2.UpdateSetupResponse(success=True) + setup.name = request.name + setup.current_setup_version.content.CopyFrom(request.content) + return setup_pb2.UpdateSetupResponse( + success=True, setup=setup, setup_version=setup.current_setup_version + ) def DeleteSetup( self, request: setup_pb2.DeleteSetupRequest, context: grpc.ServicerContext ) -> setup_pb2.DeleteSetupResponse: if request.setup_id not in self.setups: - msg = f"DELETE setup_id = {request.setup_id} | setup_id DOESN'T EXIST" - logger.warning(msg) context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) + context.set_details(f"setup_id = {request.setup_id} DOESN'T EXIST") return setup_pb2.DeleteSetupResponse(success=False) - del self.setups[request.setup_id] return setup_pb2.DeleteSetupResponse(success=True) - def CreateSetupVersion( - self, request: setup_pb2.CreateSetupVersionRequest, context: grpc.ServicerContext - ) -> setup_pb2.CreateSetupVersionResponse: - try: - setup_data_version = SetupVersionData( - id=self._generate_id(), - setup_id=request.setup_id, - version=request.version, - creation_date=datetime.datetime.now(), # noqa: DTZ005 - content=dict(request.content), - ) - except ValidationError: - msg = "Validation failed for model SetupVersionData" - logger.warning(msg) - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(msg) - return setup_pb2.CreateSetupVersionResponse(success=False) - - if request.setup_id not in self.setup_versions: - self.setup_versions[request.setup_id] = {} - self.setup_versions[request.setup_id][setup_data_version.version] = setup_data_version - logger.debug("CREATE SETUP VERSION DATA %s:%s succesfull", request.setup_id, setup_data_version) - return setup_pb2.CreateSetupVersionResponse(success=True) - - def GetSetupVersion( - self, request: setup_pb2.GetSetupVersionRequest, context: grpc.ServicerContext - ) -> setup_pb2.GetSetupVersionResponse: - logger.debug("GET SETUP VERSION setup_version_id = %s.", request.setup_version_id) - - # Search for the setup version with the matching ID - setup_version = None - for setup_versions in self.setup_versions.values(): - for version_data in setup_versions.values(): - if version_data.id == request.setup_version_id: - setup_version = version_data - break - if setup_version: - break - - if setup_version is None: - msg = f"GET SETUP VERSION setup_version_id = {request.setup_version_id} | name DOESN'T EXIST" - logger.warning(msg) - context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) - return setup_pb2.GetSetupVersionResponse() - - return setup_pb2.GetSetupVersionResponse(setup_version=setup_pb2.SetupVersion(**setup_version.model_dump())) - - def SearchSetupVersions( - self, request: setup_pb2.SearchSetupVersionsRequest, context: grpc.ServicerContext - ) -> setup_pb2.SearchSetupVersionsResponse: - if request.setup_id is None or request.setup_id not in self.setup_versions: - msg = f"GET setup_id = {request.setup_id}: setup_id DOESN'T EXIST" - logger.warning(msg) + def ChangeVisibility( + self, request: setup_pb2.ChangeVisibilityRequest, context: grpc.ServicerContext + ) -> setup_pb2.ChangeVisibilityResponse: + setup = self.setups.get(request.setup_id) + if setup is None: context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) - return setup_pb2.SearchSetupVersionsResponse() - - query_setup_versions = self.setup_versions[request.setup_id] - if request.version: - query_setup_versions = {k: v for k, v in query_setup_versions.items() if request.version in k} - - return setup_pb2.SearchSetupVersionsResponse( - setup_versions=[setup_pb2.SetupVersion(**value.model_dump()) for value in query_setup_versions.values()] + context.set_details(f"setup_id = {request.setup_id} DOESN'T EXIST") + return setup_pb2.ChangeVisibilityResponse(success=False) + setup.visibility = request.visibility + return setup_pb2.ChangeVisibilityResponse( + success=True, setup=setup, setup_version=setup.current_setup_version ) - - def UpdateSetupVersion( - self, request: setup_pb2.UpdateSetupVersionRequest, context: grpc.ServicerContext - ) -> setup_pb2.UpdateSetupVersionResponse: - # Search for the setup version with the matching ID - setup_version = None - for setup_versions in self.setup_versions.values(): - for version_data in setup_versions.values(): - if version_data.id == request.setup_version_id: - setup_version = version_data - break - if setup_version: - break - - if setup_version is None: - msg = "UPDATE setup_version_id = {request.setup_version_id}: setup_version_id DOESN'T EXIST" - logger.warning(msg) - context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) - return setup_pb2.UpdateSetupVersionResponse(success=False) - - self.setup_versions[setup_version.setup_id][setup_version.version].content = json_format.MessageToDict( - request.content - ) - return setup_pb2.UpdateSetupVersionResponse(success=True) - - def DeleteSetupVersion( - self, request: setup_pb2.DeleteSetupVersionRequest, context: grpc.ServicerContext - ) -> setup_pb2.DeleteSetupVersionResponse: - # Search for the setup version with the matching ID - setup_version = None - for setup_versions in self.setup_versions.values(): - for version_data in setup_versions.values(): - if version_data.id == request.setup_version_id: - setup_version = version_data - break - if setup_version: - break - - if setup_version is None: - msg = f"DELETE name = {request.setup_version_id} | name DOESN'T EXIST" - logger.warning(msg) - context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) - return setup_pb2.DeleteSetupVersionResponse(success=False) - - # Delete only the specific version, not all versions for this setup - del self.setup_versions[setup_version.setup_id][setup_version.version] - # If this was the last version for this setup, remove the setup entry as well - if not self.setup_versions[setup_version.setup_id]: - del self.setup_versions[setup_version.setup_id] - return setup_pb2.DeleteSetupVersionResponse(success=True) - - def ListSetups( - self, request: setup_pb2.ListSetupsRequest, context: grpc.ServicerContext - ) -> setup_pb2.ListSetupsResponse: - """List setups with optional filtering and pagination. - - Args: - request: ListSetupsRequest with organisation_id, owner_id, limit, offset - context: gRPC context - - Returns: - ListSetupsResponse: Response containing setups and total_count - """ - try: - # Start with all setups - filtered_setups = list(self.setups.values()) - - # Apply filters - if request.organisation_id: - filtered_setups = [s for s in filtered_setups if s.organisation_id == request.organisation_id] - - if request.owner_id: - filtered_setups = [s for s in filtered_setups if s.owner_id == request.owner_id] - - # Get total count before pagination - total_count = len(filtered_setups) - - # Apply pagination - offset = max(0, request.offset) - limit = request.limit if request.limit > 0 else len(filtered_setups) - paginated_setups = filtered_setups[offset : offset + limit] - - # Convert to proto messages - setup_protos = [setup_pb2.Setup(**s.model_dump()) for s in paginated_setups] - - logger.info(f"Listed {len(setup_protos)} setups (total: {total_count})") - return setup_pb2.ListSetupsResponse(setups=setup_protos, total_count=total_count) - - except Exception as e: - context.set_code(grpc.StatusCode.INTERNAL) - context.set_details(f"Internal error: {e!s}") - logger.error(f"Error in ListSetups: {e}", exc_info=True) - return setup_pb2.ListSetupsResponse(setups=[], total_count=0) diff --git a/tests/services/setup/test_default_setup.py b/tests/services/setup/test_default_setup.py new file mode 100644 index 00000000..4e18bd75 --- /dev/null +++ b/tests/services/setup/test_default_setup.py @@ -0,0 +1,12 @@ +"""Tests for the concrete create_service_setup on the strategy ABC.""" + +from digitalkin.services.setup.default_setup import DefaultSetup + + +class TestCreateServiceSetup: + """create_service_setup delegates to create_setup with name + content only.""" + + async def test_creates_service_setup(self) -> None: + setup = await DefaultSetup().create_service_setup("Nikita", {"branding": True}) + assert setup.name == "Nikita" + assert setup.current_setup_version.content == {"branding": True} diff --git a/tests/services/setup/test_grpc_setup.py b/tests/services/setup/test_grpc_setup.py index 724a624d..81a12fa2 100644 --- a/tests/services/setup/test_grpc_setup.py +++ b/tests/services/setup/test_grpc_setup.py @@ -1,11 +1,9 @@ -"""Test the grpc service.""" +"""Tests for GrpcSetup against the 5-RPC SetupService protocol.""" import asyncio import datetime -import secrets -import string -from unittest.mock import AsyncMock, Mock from concurrent import futures +from unittest.mock import AsyncMock, Mock import grpc import grpc_testing @@ -15,22 +13,19 @@ setup_service_pb2, setup_service_pb2_grpc, ) -from freezegun import freeze_time -from digitalkin.grpc_servers.exceptions import PermissionDeniedError +from digitalkin.grpc_servers.exceptions import PermissionDeniedError, ServerError from digitalkin.grpc_servers.utils.circuit_breaker import CircuitBreaker from digitalkin.models.grpc_servers.models import ClientConfig from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode +from digitalkin.services.setup.exceptions import SetupServiceError from digitalkin.services.setup.grpc_setup import GrpcSetup -from digitalkin.services.setup.setup_strategy import SetupData, SetupVersionData +from digitalkin.services.setup.setup_strategy import SetupData from mock_setup_servicer import MockSetupServicer from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext -service_instance = MockSetupServicer() service_name = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] -alphabet = string.ascii_letters + string.digits - @pytest.fixture def thread_pool(): @@ -51,10 +46,7 @@ def test_channel() -> grpc_testing.Channel: Returns: Mock gRPC Channel """ - # Create a strict real time test clock - test_clock = grpc_testing.strict_real_time() - # Create a test channel with our service descriptor and our fake servicer - return grpc_testing.channel([service_name], test_clock) + return grpc_testing.channel([service_name], grpc_testing.strict_real_time()) @pytest.fixture @@ -69,12 +61,11 @@ def mock_servicer() -> MockSetupServicer: @pytest.fixture def client(test_channel: grpc_testing.Channel) -> GrpcSetup: - """Instantiate a GrpcSetupService client that uses the test channel. + """Instantiate a GrpcSetup client that uses the test channel. Returns: gRPC client as GrpcSetup """ - # Create a dummy ServerConfig; its values are not used since we override _init_channel. dummy_config = ClientConfig( host="[::]", port=50151, @@ -83,221 +74,95 @@ def client(test_channel: grpc_testing.Channel) -> GrpcSetup: credentials=None, ) client = GrpcSetup() - # emulate real instance client.__post_init__(dummy_config) - - # Override the channel and stub to use our test channel client.stub = AsyncStubWrapper(setup_service_pb2_grpc.SetupServiceStub(test_channel)) return client -def random_string(number: int = 16) -> str: - return "".join(secrets.choice(alphabet) for _ in range(number)) - - -@pytest.fixture -@freeze_time("2025-04-01 12:00:01") -def generate_setup_version_obj() -> SetupVersionData: - setup_id = random_string() - return SetupVersionData( - id=random_string(), - setup_id=setup_id, - version="v" + random_string(8), - content={random_string(8): random_string(8) for _ in range(5)}, - creation_date=datetime.datetime.now(), # noqa: DTZ005 +def _seed_setup(mock_servicer: MockSetupServicer, name: str = "seeded") -> setup_pb2.Setup: + """Create a setup directly in the mock servicer's store.""" + response = mock_servicer.CreateSetup( + setup_pb2.CreateSetupRequest(name=name, content={"k": "v"}), FakeContext() ) + return mock_servicer.setups[response.setup.id] -@pytest.fixture -def generate_setup_obj(generate_setup_version_obj: SetupVersionData) -> SetupData: - # Create registration request with test setup data - return SetupData( - id=generate_setup_version_obj.setup_id, - name=random_string(), - organisation_id=random_string(), - owner_id=random_string(), - module_id=random_string(), - current_setup_version=generate_setup_version_obj, - ) +def _exchange(client_call, test_channel: grpc_testing.Channel, method: str, servicer_fn): + """Intercept the pending RPC, run the servicer, terminate, return (request, result-getter).""" + method_desc = service_name.methods_by_name[method] + _, request, rpc = test_channel.take_unary_unary(method_desc) + context = FakeContext() + response = servicer_fn(request, context) + rpc.send_initial_metadata(()) + rpc.terminate(response, (), context._code or grpc.StatusCode.OK, context._details or "") + return request class TestCreateSetup: - """Tests for create_setup() method. - - Verifies successful setup creation, request validation, and error handling - for invalid data and duplicate names. - """ + """create_setup sends {name, content} and assembles SetupData from the response.""" - @freeze_time("2025-04-01 12:00:01") @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_create_setup_request_creation_success( + def test_create_setup_success( self, client: GrpcSetup, test_channel: grpc_testing.Channel, - generate_setup_obj: SetupData, - generate_setup_version_obj: SetupVersionData, + mock_servicer: MockSetupServicer, thread_pool: futures.ThreadPoolExecutor, ) -> None: - """Test successful create_setup with a good request. - - Verifies that create_setup create the good request. - - Args: - grpc_test_server: Mock gRPC server for testing. - """ - # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) - - # Get the service and method descriptor. - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - method_desc = service_desc.methods_by_name["CreateSetup"] - - # Intercept the pending unary-unary call. - _, request, rpc = test_channel.take_unary_unary(method_desc) - - # Use grpc_testing to send the response back to the client. - rpc.send_initial_metadata(()) - rpc.terminate( - # use the servicer to emulate a real request handling from a server - setup_pb2.CreateSetupResponse(success=True), - (), - grpc.StatusCode.OK, - "", - ) + future = thread_pool.submit(asyncio.run, client.create_setup({"name": "my setup", "content": {"a": 1}})) + request = _exchange(future, test_channel, "CreateSetup", mock_servicer.CreateSetup) + + # The client only sends name + content — identifiers derive server-side. + assert request.name == "my setup" + assert dict(request.content) == {"a": 1} - # Verify that the client call returns success. result = future.result() - assert result.success is True - - # Verify the request correspond to the setup data - assert request.name == generate_setup_obj.name - assert request.organisation_id == generate_setup_obj.organisation_id - assert request.owner_id == generate_setup_obj.owner_id - assert request.current_setup_version.setup_id == generate_setup_obj.current_setup_version.setup_id - assert request.current_setup_version.version == generate_setup_obj.current_setup_version.version - assert ( - request.current_setup_version.creation_date.ToDatetime() - == generate_setup_obj.current_setup_version.creation_date - ) - assert dict(request.current_setup_version.content) == generate_setup_obj.current_setup_version.content + assert isinstance(result, SetupData) + assert result.name == "my setup" + assert result.organisation_id == "ctx-org" + assert result.owner_id == "ctx-owner" + assert result.module_id == "ctx-module" + assert result.status == "READY" + assert result.visibility == "VISIBILITY_PRIVATE" + # Version arrived via the response-level sibling (fallback merge path). + assert result.current_setup_version.content == {"a": 1} + assert result.current_setup_version.setup_id == result.id + + @pytest.mark.grpc + @pytest.mark.validation + async def test_create_setup_missing_fields_no_rpc(self, client: GrpcSetup) -> None: + with pytest.raises(ValueError, match="name and content"): + await client.create_setup({"name": "", "content": {"a": 1}}) + with pytest.raises(ValueError, match="name and content"): + await client.create_setup({"name": "x", "content": "not-a-dict"}) - @freeze_time("2025-04-01 12:00:01") @pytest.mark.grpc @pytest.mark.edge_case - async def test_create_setup_permission_denied(self, client: GrpcSetup, generate_setup_obj: SetupData) -> None: + async def test_create_setup_permission_denied(self, client: GrpcSetup) -> None: """setup's handler lets a permission error pass through unwrapped (not SetupServiceError).""" CircuitBreaker.remove("SetupService") client.stub = Mock() client.stub.CreateSetup = AsyncMock(side_effect=PermissionDeniedError("[/SetupService/CreateSetup] denied")) with pytest.raises(PermissionDeniedError): - await client.create_setup(generate_setup_obj.model_dump()) + await client.create_setup({"name": "x", "content": {}}) - @freeze_time("2025-04-01 12:00:01") @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_create_setup_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_obj: SetupData, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successful create_setup. - - Verifies that create_setup RPC call with a valid request using the fake servicer. - - Args: - grpc_test_server: Mock gRPC server for testing. - """ - # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) - - # Get the service and method descriptor. - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - method_desc = service_desc.methods_by_name["CreateSetup"] - - # Intercept the pending unary-unary call. - _, _request, rpc = test_channel.take_unary_unary(method_desc) - - # Use grpc_testing to send the response back to the client. - rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupRequest(**{ - k: v for (k, v) in generate_setup_obj.model_dump().items() if k not in ("id") - }) - - rpc.terminate( - # use the servicer to emulate a real request handling from a server - mock_servicer.CreateSetup(request_obj, FakeContext()), - (), - grpc.StatusCode.OK, - "", - ) - - # Verify that the client call returns success. - result = future.result() - assert result.success is True - - setup = next( - filter( - lambda obj: getattr(obj, "name", None) == generate_setup_obj.name, - mock_servicer.setups.values(), - ) - ) - - assert isinstance(setup, SetupData) - assert setup.name == generate_setup_obj.name - assert setup.organisation_id == generate_setup_obj.organisation_id - assert setup.owner_id == generate_setup_obj.owner_id - assert setup.current_setup_version.setup_id == generate_setup_obj.current_setup_version.setup_id - assert setup.current_setup_version.version == generate_setup_obj.current_setup_version.version - assert setup.current_setup_version.creation_date == generate_setup_obj.current_setup_version.creation_date - assert setup.current_setup_version.content == generate_setup_obj.current_setup_version.content + @pytest.mark.edge_case + async def test_create_setup_server_refusal(self, client: GrpcSetup) -> None: + CircuitBreaker.remove("SetupService") + client.stub = Mock() + client.stub.CreateSetup = AsyncMock(return_value=setup_pb2.CreateSetupResponse(success=False)) - # Test RegisterModule - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_create_setup_validation_error( - self, - client: GrpcSetup, - generate_setup_version_obj: SetupVersionData, - generate_setup_obj: SetupData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test registration of a duplicate module. - - Verifies that attempting to register a module with an ID that already exists - results in an error response with ALREADY_EXISTS status code. - - Args: - grpc_test_server: Mock gRPC server for testing. - module_registry_obj: Pre-registered module fixture for testing duplicates. - """ - # Try to register a module with an ID that already exists - # Convert the module object to a request, excluding status and message fields - generate_setup_obj.name = [] - generate_setup_obj.current_setup_version = None - - # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump(warnings=False))) - with pytest.raises(ValueError, match="Validation failed for Setup Creation"): - future.result() + with pytest.raises(SetupServiceError, match="refused"): + await client.create_setup({"name": "x", "content": {}}) class TestGetSetup: - """Tests for get_setup() method. - - Verifies successful retrieval of setup data, handling of non-existent setups, - and retrieval with specific versions. - """ + """get_setup reads by id, optionally pinning a version.""" - @freeze_time("2025-04-01 12:00:01") @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke @@ -306,954 +171,214 @@ def test_get_setup_success( client: GrpcSetup, test_channel: grpc_testing.Channel, mock_servicer: MockSetupServicer, - generate_setup_obj: SetupData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successfully retrieving a setup. - - Verifies that get_setup returns the correct setup data. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetup"] - get_method_desc = service_desc.methods_by_name["GetSetup"] - - # First create a setup - create_future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupRequest(**{ - k: v for (k, v) in generate_setup_obj.model_dump().items() if k != "id" - }) - create_response = mock_servicer.CreateSetup(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Get the created setup's ID - created_setup_id = next(iter(mock_servicer.setups.keys())) - - # Now get the setup - get_future = thread_pool.submit(asyncio.run, client.get_setup({"setup_id": created_setup_id})) - _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) - - assert get_request.setup_id == created_setup_id - - get_context = FakeContext() - get_response = mock_servicer.GetSetup(get_request, get_context) - get_rpc.send_initial_metadata(()) - get_rpc.terminate(get_response, (), grpc.StatusCode.OK, "") - - result = get_future.result() - assert result is not None - assert result.name == generate_setup_obj.name - assert result.organisation_id == generate_setup_obj.organisation_id - assert result.owner_id == generate_setup_obj.owner_id - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_get_setup_not_found( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test getting a non-existent setup raises error. - - Verifies that attempting to get a non-existent setup results in error. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - get_method_desc = service_desc.methods_by_name["GetSetup"] - - get_future = thread_pool.submit(asyncio.run, client.get_setup({"setup_id": "nonexistent_id"})) - _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) - - get_context = FakeContext() - get_response = mock_servicer.GetSetup(get_request, get_context) - get_rpc.send_initial_metadata(()) - get_rpc.terminate(get_response, (), get_context._code, get_context._details) - - with pytest.raises(Exception): - get_future.result() - - -class TestUpdateSetup: - """Tests for update_setup() method. - - Verifies successful updates, handling of non-existent setups, and partial updates - of setup data. - """ - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_update_setup_servicer_direct( - self, - mock_servicer: MockSetupServicer, - generate_setup_obj: SetupData, - ) -> None: - """Test UpdateSetup servicer directly without grpc_testing interception. - - This tests the servicer logic without the grpc channel layer, - avoiding grpc_testing framework issues. - """ - # First create a setup in the servicer - create_request = setup_pb2.CreateSetupRequest( - name=generate_setup_obj.name, - organisation_id=generate_setup_obj.organisation_id, - owner_id=generate_setup_obj.owner_id, - module_id=generate_setup_obj.module_id, - current_setup_version=setup_pb2.SetupVersion(**generate_setup_obj.current_setup_version.model_dump()), - ) - create_context = FakeContext() - create_response = mock_servicer.CreateSetup(create_request, create_context) - assert create_response.success is True - - # Get the created setup's ID - created_setup_id = next(iter(mock_servicer.setups.keys())) - - # Now test UpdateSetup servicer method directly - update_request = setup_pb2.UpdateSetupRequest( - setup_id=created_setup_id, - name="Updated Name", - owner_id="new_owner_id", - current_setup_version=None, - ) - update_context = FakeContext() - update_response = mock_servicer.UpdateSetup(update_request, update_context) - - # Verify the update succeeded - assert update_response.success is True - assert update_context._code == grpc.StatusCode.OK - - # Verify the data was actually updated - updated_setup = mock_servicer.setups[created_setup_id] - assert updated_setup.name == "Updated Name" - assert updated_setup.owner_id == "new_owner_id" - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_update_setup_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_obj: SetupData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successfully updating a setup. - - Verifies that update_setup updates the setup data correctly. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - - # First, manually add a setup to the mock servicer to avoid the create call - setup_id = "test_setup_id_" + random_string(8) - test_setup = SetupData( - id=setup_id, - name="Original Name", - organisation_id=generate_setup_obj.organisation_id, - owner_id="original_owner_id", - module_id=generate_setup_obj.module_id, - current_setup_version=generate_setup_obj.current_setup_version, - ) - mock_servicer.setups[setup_id] = test_setup - - # Now update the setup - updated_data = { - "id": setup_id, - "name": "Updated Name", - "owner_id": "new_owner_id", - "module_id": generate_setup_obj.module_id, - "organisation_id": generate_setup_obj.organisation_id, - "current_setup_version": generate_setup_obj.current_setup_version, - } - - # Start the update call - update_future = thread_pool.submit(asyncio.run, client.update_setup(updated_data)) - - # Intercept the call - update_method_desc = service_desc.methods_by_name["UpdateSetup"] - _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) - - # Verify request - assert update_request.setup_id == setup_id - assert update_request.name == "Updated Name" - assert update_request.owner_id == "new_owner_id" - - # Process with mock servicer - update_context = FakeContext() - update_response = mock_servicer.UpdateSetup(update_request, update_context) - - # Send response - update_rpc.send_initial_metadata(()) - update_rpc.terminate(update_response, (), grpc.StatusCode.OK, "") - - # Get result - result = update_future.result(timeout=5.0) - assert result is True - - # Verify the update in mock servicer - updated_setup = mock_servicer.setups[setup_id] - assert updated_setup.name == "Updated Name" - assert updated_setup.owner_id == "new_owner_id" - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_update_setup_not_found( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_obj: SetupData, thread_pool: futures.ThreadPoolExecutor, ) -> None: - """Test updating a non-existent setup returns False. - - Verifies that attempting to update a non-existent setup returns False. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - update_method_desc = service_desc.methods_by_name["UpdateSetup"] + seeded = _seed_setup(mock_servicer) - updated_data = generate_setup_obj.model_dump() - updated_data["id"] = "nonexistent_id" + future = thread_pool.submit(asyncio.run, client.get_setup({"setup_id": seeded.id})) + request = _exchange(future, test_channel, "GetSetup", mock_servicer.GetSetup) - update_future = thread_pool.submit(asyncio.run, client.update_setup(updated_data)) - _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) + assert request.setup_id == seeded.id + assert not request.HasField("version") # no empty-string presence - update_context = FakeContext() - update_response = mock_servicer.UpdateSetup(update_request, update_context) - update_rpc.send_initial_metadata(()) - # When setup doesn't exist, return OK status with success=False - update_rpc.terminate(update_response, (), grpc.StatusCode.OK, "") - - result = update_future.result() - assert result is False - - -class TestDeleteSetup: - """Tests for delete_setup() method. - - Verifies successful deletion of setups and proper handling of non-existent setups. - """ + result = future.result() + assert result.id == seeded.id + assert result.name == "seeded" + # Embedded current_setup_version wins (preferred merge path). + assert result.current_setup_version.content == {"k": "v"} - @freeze_time("2025-04-01 12:00:01") @pytest.mark.grpc @pytest.mark.integration - @pytest.mark.smoke - def test_delete_setup_success( + def test_get_setup_pins_version( self, client: GrpcSetup, test_channel: grpc_testing.Channel, mock_servicer: MockSetupServicer, - generate_setup_obj: SetupData, thread_pool: futures.ThreadPoolExecutor, ) -> None: - """Test successfully deleting a setup. - - Verifies that delete_setup removes the setup from storage. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetup"] - delete_method_desc = service_desc.methods_by_name["DeleteSetup"] - - # First create a setup - create_future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupRequest(**{ - k: v for (k, v) in generate_setup_obj.model_dump().items() if k != "id" - }) - create_response = mock_servicer.CreateSetup(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Get the created setup's ID - created_setup_id = next(iter(mock_servicer.setups.keys())) + seeded = _seed_setup(mock_servicer) - # Delete the setup - delete_future = thread_pool.submit(asyncio.run, client.delete_setup({"setup_id": created_setup_id})) - _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) + future = thread_pool.submit(asyncio.run, client.get_setup({"setup_id": seeded.id, "version": "1.0.0"})) + request = _exchange(future, test_channel, "GetSetup", mock_servicer.GetSetup) - assert delete_request.setup_id == created_setup_id - - delete_context = FakeContext() - delete_response = mock_servicer.DeleteSetup(delete_request, delete_context) - delete_rpc.send_initial_metadata(()) - delete_rpc.terminate(delete_response, (), grpc.StatusCode.OK, "") - - result = delete_future.result() - assert result is True - - # Verify deletion in mock servicer - assert created_setup_id not in mock_servicer.setups + assert request.HasField("version") + assert request.version == "1.0.0" + future.result() @pytest.mark.grpc @pytest.mark.integration @pytest.mark.validation - def test_delete_setup_not_found( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test deleting a non-existent setup returns False. - - Verifies that attempting to delete a non-existent setup returns False. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - delete_method_desc = service_desc.methods_by_name["DeleteSetup"] - - delete_future = thread_pool.submit(asyncio.run, client.delete_setup({"setup_id": "nonexistent_id"})) - _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) - - delete_context = FakeContext() - delete_response = mock_servicer.DeleteSetup(delete_request, delete_context) - delete_rpc.send_initial_metadata(()) - # When setup doesn't exist, return OK status with success=False - delete_rpc.terminate(delete_response, (), grpc.StatusCode.OK, "") - - result = delete_future.result() - assert result is False - - -class TestSetupVersionOperations: - """Tests for setup version CRUD operations. - - Verifies creation, retrieval, search, update, and deletion of setup versions, - including error handling for non-existent versions. - """ - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_create_setup_version_request_creation_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successful create_setup_version with a good request. - - Verifies that create_setup create the good request. - - Args: - grpc_test_server: Mock gRPC server for testing. - """ - # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - - # Get the service and method descriptor. - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - method_desc = service_desc.methods_by_name["CreateSetupVersion"] - - # Intercept the pending unary-unary call. - _, request, rpc = test_channel.take_unary_unary(method_desc) - - # Use grpc_testing to send the response back to the client. - rpc.send_initial_metadata(()) - rpc.terminate( - # use the servicer to emulate a real request handling from a server - setup_pb2.CreateSetupVersionResponse(success=True), - (), - grpc.StatusCode.OK, - "", - ) - - # Verify that the client call returns success. - result = future.result() - assert result.success is True - - # Verify the request correspond to the setup data - assert request.setup_id == generate_setup_version_obj.setup_id - assert request.version == generate_setup_version_obj.version - assert dict(request.content) == generate_setup_version_obj.content - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_create_setup_version_success( + def test_get_setup_not_found( self, client: GrpcSetup, test_channel: grpc_testing.Channel, mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, thread_pool: futures.ThreadPoolExecutor, ) -> None: - """Test successful create_setup_version. - - Verifies that create_setup_version RPC call with a valid request using the fake servicer. - - Args: - grpc_test_server: Mock gRPC server for testing. - """ - # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - - # Get the service and method descriptor. - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - method_desc = service_desc.methods_by_name["CreateSetupVersion"] - - # Intercept the pending unary-unary call. - _, _request, rpc = test_channel.take_unary_unary(method_desc) - - # Use grpc_testing to send the response back to the client. - rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - - rpc.terminate( - # use the servicer to emulate a real request handling from a server - mock_servicer.CreateSetupVersion(request_obj, FakeContext()), - (), - grpc.StatusCode.OK, - "", - ) - - # Verify that the client call returns success. - result = future.result() - assert result.success is True - - setup_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] + future = thread_pool.submit(asyncio.run, client.get_setup({"setup_id": "nonexistent_id"})) + _exchange(future, test_channel, "GetSetup", mock_servicer.GetSetup) - assert isinstance(setup_version, SetupVersionData) - # Verify the request correspond to the setup data - assert setup_version.setup_id == generate_setup_version_obj.setup_id - assert setup_version.version == generate_setup_version_obj.version - assert setup_version.creation_date == generate_setup_version_obj.creation_date - assert setup_version.content == generate_setup_version_obj.content - - # Test RegisterModule - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_create_setup_version_validation_error( - self, - client: GrpcSetup, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test registration of a duplicate module. - - Verifies that attempting to register a module with an ID that already exists - results in an error response with ALREADY_EXISTS status code. - - Args: - grpc_test_server: Mock gRPC server for testing. - module_registry_obj: Pre-registered module fixture for testing duplicates. - """ - # Try to register a module with an ID that already exists - # Convert the module object to a request, excluding status and message fields - generate_setup_version_obj.creation_date = [] - generate_setup_version_obj.content = "" - - # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump(warnings=False))) - with pytest.raises(ValueError, match="Validation failed for Setup Version Creation"): + with pytest.raises(ServerError, match="NOT_FOUND"): future.result() - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_get_setup_version_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successfully retrieving a setup version. - - Verifies that get_setup_version returns the correct setup version data. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] - get_method_desc = service_desc.methods_by_name["GetSetupVersion"] - - # First create a setup version - create_future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Get the created version's ID (it's stored as version key in mock servicer) - created_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] - - # Now get the setup version by ID - get_future = thread_pool.submit(asyncio.run, client.get_setup_version({"setup_version_id": created_version.id})) - _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) - - assert get_request.setup_version_id == created_version.id - - get_context = FakeContext() - get_response = mock_servicer.GetSetupVersion(get_request, get_context) - get_rpc.send_initial_metadata(()) - get_rpc.terminate(get_response, (), grpc.StatusCode.OK, "") - - result = get_future.result() - assert result is not None - assert result.setup_id == generate_setup_version_obj.setup_id - assert result.version == generate_setup_version_obj.version - assert result.content == generate_setup_version_obj.content - @pytest.mark.grpc - @pytest.mark.integration @pytest.mark.validation - def test_get_setup_version_not_found( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test getting a non-existent setup version raises error. - - Verifies that attempting to get a non-existent setup version results in error. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - get_method_desc = service_desc.methods_by_name["GetSetupVersion"] - - get_future = thread_pool.submit(asyncio.run, client.get_setup_version({"setup_version_id": "nonexistent_version_id"})) - _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) + async def test_get_setup_missing_id_no_rpc(self, client: GrpcSetup) -> None: + with pytest.raises(ValueError, match="setup_id is required"): + await client.get_setup({}) - get_context = FakeContext() - get_response = mock_servicer.GetSetupVersion(get_request, get_context) - get_rpc.send_initial_metadata(()) - get_rpc.terminate(get_response, (), get_context._code, get_context._details) - with pytest.raises(Exception): - get_future.result() +class TestUpdateSetup: + """update_setup sends {setup_id, name, content} and returns the updated SetupData.""" - @freeze_time("2025-04-01 12:00:01") @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_search_setup_versions_success( + def test_update_setup_success( self, client: GrpcSetup, test_channel: grpc_testing.Channel, mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, thread_pool: futures.ThreadPoolExecutor, ) -> None: - """Test successfully searching setup versions. - - Verifies that search_setup_versions returns matching versions. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] - search_method_desc = service_desc.methods_by_name["SearchSetupVersions"] - - # Create a setup version - create_future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Search for versions - search_future = thread_pool.submit( + seeded = _seed_setup(mock_servicer) + + future = thread_pool.submit( asyncio.run, - client.search_setup_versions( - {"setup_id": generate_setup_version_obj.setup_id, "version": generate_setup_version_obj.version} - ), + client.update_setup({"setup_id": seeded.id, "name": "renamed", "content": {"a": 2}}), ) - _, search_request, search_rpc = test_channel.take_unary_unary(search_method_desc) + request = _exchange(future, test_channel, "UpdateSetup", mock_servicer.UpdateSetup) - assert search_request.setup_id == generate_setup_version_obj.setup_id - assert search_request.version == generate_setup_version_obj.version + assert request.setup_id == seeded.id + assert request.name == "renamed" + assert dict(request.content) == {"a": 2} - search_context = FakeContext() - search_response = mock_servicer.SearchSetupVersions(search_request, search_context) - search_rpc.send_initial_metadata(()) - search_rpc.terminate(search_response, (), grpc.StatusCode.OK, "") - - result = search_future.result() - assert len(result) == 1 - assert result[0].setup_id == generate_setup_version_obj.setup_id - assert result[0].version == generate_setup_version_obj.version + result = future.result() + assert result.name == "renamed" + assert result.current_setup_version.content == {"a": 2} @pytest.mark.grpc - @pytest.mark.integration @pytest.mark.edge_case - def test_search_setup_versions_empty_results( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test searching for setup versions with no results. + async def test_update_setup_server_refusal(self, client: GrpcSetup) -> None: + CircuitBreaker.remove("SetupService") + client.stub = Mock() + client.stub.UpdateSetup = AsyncMock(return_value=setup_pb2.UpdateSetupResponse(success=False)) - Verifies that search_setup_versions returns empty list when no matches found. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - search_method_desc = service_desc.methods_by_name["SearchSetupVersions"] + with pytest.raises(SetupServiceError, match="refused"): + await client.update_setup({"setup_id": "s1", "name": "x", "content": {}}) - search_future = thread_pool.submit( - asyncio.run, client.search_setup_versions({"setup_id": "nonexistent_setup", "version": "v1.0.0"}) - ) - _, search_request, search_rpc = test_channel.take_unary_unary(search_method_desc) + @pytest.mark.grpc + @pytest.mark.validation + async def test_update_setup_missing_fields_no_rpc(self, client: GrpcSetup) -> None: + with pytest.raises(ValueError, match="setup_id, name and content"): + await client.update_setup({"setup_id": "s1", "name": "", "content": {}}) - search_context = FakeContext() - search_response = mock_servicer.SearchSetupVersions(search_request, search_context) - search_rpc.send_initial_metadata(()) - search_rpc.terminate(search_response, (), search_context._code, search_context._details) - with pytest.raises(Exception): - search_future.result() +class TestDeleteSetup: + """delete_setup returns the server's success flag.""" - @freeze_time("2025-04-01 12:00:01") @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_update_setup_version_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successfully updating a setup version. - - Verifies that update_setup_version updates the version data correctly. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] - update_method_desc = service_desc.methods_by_name["UpdateSetupVersion"] - - # First create a setup version - create_future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Get the created version - created_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] - - # Update the setup version - updated_data = generate_setup_version_obj.model_dump() - updated_data["id"] = created_version.id - updated_data["content"] = {"updated_key": "updated_value"} - - update_future = thread_pool.submit(asyncio.run, client.update_setup_version(updated_data)) - _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) - - assert update_request.setup_version_id == created_version.id - - update_context = FakeContext() - update_response = mock_servicer.UpdateSetupVersion(update_request, update_context) - update_rpc.send_initial_metadata(()) - update_rpc.terminate(update_response, (), grpc.StatusCode.OK, "") - - result = update_future.result() - assert result is True - - # Verify the update in mock servicer - updated_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] - assert updated_version.content == {"updated_key": "updated_value"} - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_update_setup_version_not_found( + def test_delete_setup_success( self, client: GrpcSetup, test_channel: grpc_testing.Channel, mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, thread_pool: futures.ThreadPoolExecutor, ) -> None: - """Test updating a non-existent setup version returns False. + seeded = _seed_setup(mock_servicer) - Verifies that attempting to update a non-existent setup version returns False. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - update_method_desc = service_desc.methods_by_name["UpdateSetupVersion"] + future = thread_pool.submit(asyncio.run, client.delete_setup({"setup_id": seeded.id})) + request = _exchange(future, test_channel, "DeleteSetup", mock_servicer.DeleteSetup) - updated_data = generate_setup_version_obj.model_dump() - updated_data["id"] = "nonexistent_version_id" + assert request.setup_id == seeded.id + assert future.result() is True + assert seeded.id not in mock_servicer.setups - update_future = thread_pool.submit(asyncio.run, client.update_setup_version(updated_data)) - _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) + @pytest.mark.grpc + @pytest.mark.validation + async def test_delete_setup_missing_id_no_rpc(self, client: GrpcSetup) -> None: + with pytest.raises(ValueError, match="setup_id is required"): + await client.delete_setup({}) - update_context = FakeContext() - update_response = mock_servicer.UpdateSetupVersion(update_request, update_context) - update_rpc.send_initial_metadata(()) - # When setup version doesn't exist, return OK status with success=False - update_rpc.terminate(update_response, (), grpc.StatusCode.OK, "") - result = update_future.result() - assert result is False +class TestChangeVisibility: + """change_visibility encodes the scope fail-closed and returns the updated setup.""" - @freeze_time("2025-04-01 12:00:01") @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_delete_setup_version_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successfully deleting a setup version. - - Verifies that delete_setup_version removes the version from storage. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] - delete_method_desc = service_desc.methods_by_name["DeleteSetupVersion"] - - # First create a setup version - create_future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Get the created version - created_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] - - # Delete the setup version - delete_future = thread_pool.submit(asyncio.run, client.delete_setup_version({"setup_version_id": created_version.id})) - _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) - - assert delete_request.setup_version_id == created_version.id - - delete_context = FakeContext() - delete_response = mock_servicer.DeleteSetupVersion(delete_request, delete_context) - delete_rpc.send_initial_metadata(()) - delete_rpc.terminate(delete_response, (), grpc.StatusCode.OK, "") - - result = delete_future.result() - assert result is True - - # Verify deletion in mock servicer - assert generate_setup_version_obj.setup_id not in mock_servicer.setup_versions - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_delete_setup_version_not_found( + @pytest.mark.parametrize( + ("scope", "proto_name"), + [ + ("public", "VISIBILITY_PUBLIC"), + ("internal", "VISIBILITY_INTERNAL"), + ("private", "VISIBILITY_PRIVATE"), + ], + ) + def test_change_visibility_success( self, client: GrpcSetup, test_channel: grpc_testing.Channel, mock_servicer: MockSetupServicer, thread_pool: futures.ThreadPoolExecutor, + scope: str, + proto_name: str, ) -> None: - """Test deleting a non-existent setup version returns False. - - Verifies that attempting to delete a non-existent setup version returns False. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - delete_method_desc = service_desc.methods_by_name["DeleteSetupVersion"] - - delete_future = thread_pool.submit(asyncio.run, client.delete_setup_version({"setup_version_id": "nonexistent_version_id"})) - _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) - - delete_context = FakeContext() - delete_response = mock_servicer.DeleteSetupVersion(delete_request, delete_context) - delete_rpc.send_initial_metadata(()) - # When setup version doesn't exist, return OK status with success=False - delete_rpc.terminate(delete_response, (), grpc.StatusCode.OK, "") + seeded = _seed_setup(mock_servicer) - result = delete_future.result() - assert result is False - - -class TestListSetups: - """Tests for list_setups() method. + future = thread_pool.submit( + asyncio.run, client.change_visibility({"setup_id": seeded.id, "visibility": scope}) + ) + request = _exchange(future, test_channel, "ChangeVisibility", mock_servicer.ChangeVisibility) - Verifies listing all setups, filtering capabilities, and pagination support. - """ + assert request.setup_id == seeded.id + assert request.visibility == setup_pb2.Visibility.Value(proto_name) - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_list_setups_success( - self, - client, - test_channel, - thread_pool, - mock_servicer, - generate_setup_obj, - ) -> None: - """Test successfully listing all setups. - - Verifies that ListSetups returns all setups when no filters are applied. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetup"] - - # Create three setups - for i in range(3): - create_future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) - _, create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_context = FakeContext() - create_response = mock_servicer.CreateSetup(create_request, create_context) - create_rpc.send_initial_metadata(()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # List all setups - list_method_desc = service_desc.methods_by_name["ListSetups"] - list_future = thread_pool.submit(asyncio.run, client.list_setups({})) - _, list_request, list_rpc = test_channel.take_unary_unary(list_method_desc) - - list_context = FakeContext() - list_response = mock_servicer.ListSetups(list_request, list_context) - list_rpc.send_initial_metadata(()) - list_rpc.terminate(list_response, (), grpc.StatusCode.OK, "") - - result = list_future.result() - assert result["total_count"] == 3 - assert len(result["setups"]) == 3 + result = future.result() + assert result.visibility == proto_name + assert mock_servicer.setups[seeded.id].visibility == setup_pb2.Visibility.Value(proto_name) @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_list_setups_with_pagination( - self, - client, - test_channel, - thread_pool, - mock_servicer, - generate_setup_obj, - ) -> None: - """Test listing setups with pagination. - - Verifies that ListSetups correctly handles limit and offset. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetup"] - - # Create 5 setups - for i in range(5): - create_future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) - _, create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_context = FakeContext() - create_response = mock_servicer.CreateSetup(create_request, create_context) - create_rpc.send_initial_metadata(()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # List first 2 setups - list_method_desc = service_desc.methods_by_name["ListSetups"] - list_future = thread_pool.submit(asyncio.run, client.list_setups({"limit": 2, "offset": 0})) - _, list_request, list_rpc = test_channel.take_unary_unary(list_method_desc) - - list_context = FakeContext() - list_response = mock_servicer.ListSetups(list_request, list_context) - list_rpc.send_initial_metadata(()) - list_rpc.terminate(list_response, (), grpc.StatusCode.OK, "") - - result = list_future.result() - assert result["total_count"] == 5 - assert len(result["setups"]) == 2 - - # List next 2 setups (offset 2) - list_future2 = thread_pool.submit(asyncio.run, client.list_setups({"limit": 2, "offset": 2})) - _, list_request2, list_rpc2 = test_channel.take_unary_unary(list_method_desc) - - list_context2 = FakeContext() - list_response2 = mock_servicer.ListSetups(list_request2, list_context2) - list_rpc2.send_initial_metadata(()) - list_rpc2.terminate(list_response2, (), grpc.StatusCode.OK, "") - - result2 = list_future2.result() - assert result2["total_count"] == 5 - assert len(result2["setups"]) == 2 + @pytest.mark.validation + @pytest.mark.parametrize("scope", ["", "unspecified", "PUBLIC ", "org", None]) + async def test_change_visibility_invalid_scope_no_rpc(self, client: GrpcSetup, scope: object) -> None: + with pytest.raises(ValueError, match="invalid visibility"): + await client.change_visibility({"setup_id": "s1", "visibility": scope}) @pytest.mark.grpc - @pytest.mark.integration @pytest.mark.edge_case - def test_list_setups_empty( - self, - client, - test_channel, - thread_pool, - mock_servicer, - ) -> None: - """Test listing setups when no setups exist. - - Verifies that ListSetups returns an empty list when no setups match. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - list_method_desc = service_desc.methods_by_name["ListSetups"] - - # List setups (empty database) - list_future = thread_pool.submit(asyncio.run, client.list_setups({})) - _, list_request, list_rpc = test_channel.take_unary_unary(list_method_desc) - - list_context = FakeContext() - list_response = mock_servicer.ListSetups(list_request, list_context) - list_rpc.send_initial_metadata(()) - list_rpc.terminate(list_response, (), grpc.StatusCode.OK, "") - - result = list_future.result() - assert result["total_count"] == 0 - assert len(result["setups"]) == 0 - - -# ============================================================================ -# Regression Tests -# ============================================================================ -# This section contains tests for previously identified bugs and edge cases -# that were fixed. Each test should document the issue/PR that it addresses. -# -# Format: -# @pytest.mark.grpc -# @pytest.mark.integration -# @pytest.mark.regression -# def test_regression_issue_123(...): -# """Test for regression of issue #123. -# -# Issue: [Brief description of the bug] -# Fixed in: PR #456 / commit abc123 -# -# Verifies: [What this test checks to prevent regression] -# """ -# -# Add regression tests below as bugs are discovered and fixed. + async def test_change_visibility_server_refusal(self, client: GrpcSetup) -> None: + CircuitBreaker.remove("SetupService") + client.stub = Mock() + client.stub.ChangeVisibility = AsyncMock(return_value=setup_pb2.ChangeVisibilityResponse(success=False)) + + with pytest.raises(SetupServiceError, match="refused"): + await client.change_visibility({"setup_id": "s1", "visibility": "public"}) + + +class TestResponseMerging: + """_to_setup_data merge semantics.""" + + def test_missing_version_everywhere_raises(self) -> None: + setup = setup_pb2.Setup(id="s1", name="n", organisation_id="o", owner_id="u", module_id="m") + with pytest.raises(SetupServiceError, match="without a setup version"): + GrpcSetup._to_setup_data(setup, setup_pb2.SetupVersion()) + + def test_embedded_version_wins_over_sibling(self) -> None: + now = datetime.datetime.now(datetime.timezone.utc) + setup = setup_pb2.Setup( + id="s1", + name="n", + organisation_id="o", + owner_id="u", + module_id="m", + current_setup_version=setup_pb2.SetupVersion( + id="v-embedded", setup_id="s1", version="2.0.0", content={"a": 1}, creation_date=now + ), + ) + sibling = setup_pb2.SetupVersion(id="v-sibling", setup_id="s1", version="1.0.0", content={}, creation_date=now) + result = GrpcSetup._to_setup_data(setup, sibling) + assert result.current_setup_version.id == "v-embedded" + assert result.current_setup_version.version == "2.0.0" diff --git a/tests/services/storage/mock_storage_servicer.py b/tests/services/storage/mock_storage_servicer.py index a2f53d92..e942df09 100644 --- a/tests/services/storage/mock_storage_servicer.py +++ b/tests/services/storage/mock_storage_servicer.py @@ -47,15 +47,19 @@ def _validate_schema(self, collection: str, data: dict[str, Any]) -> None: def _create_proto_record( self, - ctx: str, + ctx: int, collection: str, record_id: str, record_data: dict[str, Any], ) -> data_pb2.StorageRecord: """Convert internal record data to proto StorageRecord. + Mirrors the dev4 server contract: the request carries only the context KIND + (ContextStorage enum); the concrete prefixed id is resolved server-side — + here from the fixed test ids. + Args: - ctx: Owner context (`missions:` or `setup_versions:`) + ctx: Context kind from the request (ContextStorage enum value) collection: Collection name record_id: Record ID record_data: The record data dictionary @@ -63,6 +67,9 @@ def _create_proto_record( Returns: data_pb2.StorageRecord: Proto storage record """ + resolved = ( + "setup_versions:test_version" if ctx == data_pb2.CONTEXT_SETUP_VERSIONS else "missions:test_mission" + ) # Convert data dict to Struct data_struct = json_format.ParseDict( record_data["data"], @@ -88,7 +95,7 @@ def _create_proto_record( update_ts.FromDatetime(update_dt) return data_pb2.StorageRecord( - context=ctx, + context=resolved, collection=collection, record_id=record_id, data_type=value, diff --git a/tests/services/storage/test_grpc_storage.py b/tests/services/storage/test_grpc_storage.py index 7f510410..8f0b085c 100644 --- a/tests/services/storage/test_grpc_storage.py +++ b/tests/services/storage/test_grpc_storage.py @@ -25,7 +25,7 @@ from digitalkin.grpc_servers.utils.circuit_breaker import CircuitBreaker from digitalkin.models.grpc_servers.circuit_breaker import CBState from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.models.services.storage import DataType +from digitalkin.models.services.storage import ContextStorage, DataType, Visibility from digitalkin.models.settings.grpc_client import get_circuit_breaker_settings, get_grpc_client_settings from digitalkin.services.storage.exceptions import StorageServiceError from digitalkin.services.storage.grpc_storage import GrpcStorage @@ -201,12 +201,9 @@ def test_store_record_success( _, request, rpc = test_channel.take_unary_unary(method_desc) # Verify request - assert request.context == MISSION_ID + assert request.context == data_pb2.CONTEXT_MISSIONS assert request.collection == collection assert request.record_id == record_id - # data_type is now a protobuf enum integer value - from agentic_mesh_protocol.storage.v1 import data_pb2 - assert request.data_type == data_pb2.OUTPUT # Mock servicer processes the request @@ -319,7 +316,7 @@ def test_store_record_with_output_type( method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] - future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type="OUTPUT")) + future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type=DataType.OUTPUT)) _, request, rpc = test_channel.take_unary_unary(method_desc) @@ -360,7 +357,7 @@ def test_store_record_with_logs_type( method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] - future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type="LOGS")) + future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type=DataType.LOGS)) _, request, rpc = test_channel.take_unary_unary(method_desc) @@ -396,7 +393,7 @@ def test_store_record_with_view_type( method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] - future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type="VIEW")) + future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type=DataType.VIEW)) _, request, rpc = test_channel.take_unary_unary(method_desc) @@ -432,7 +429,7 @@ def test_store_record_with_other_type( method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] - future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type="OTHER")) + future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type=DataType.OTHER)) _, request, rpc = test_channel.take_unary_unary(method_desc) @@ -532,7 +529,7 @@ def test_read_record_success( read_future = thread_pool.submit(asyncio.run, client.read(collection, record_id)) _, read_request, read_rpc = test_channel.take_unary_unary(read_method_desc) - assert read_request.context == MISSION_ID + assert read_request.context == data_pb2.CONTEXT_MISSIONS assert read_request.collection == collection assert read_request.record_id == record_id @@ -700,7 +697,7 @@ def test_update_record_success( update_future = thread_pool.submit(asyncio.run, client.update(collection, record_id, updated_data)) _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) - assert update_request.context == MISSION_ID + assert update_request.context == data_pb2.CONTEXT_MISSIONS assert update_request.collection == collection assert update_request.record_id == record_id @@ -826,7 +823,7 @@ def test_remove_record_success( remove_future = thread_pool.submit(asyncio.run, client.remove(collection, record_id)) _, remove_request, remove_rpc = test_channel.take_unary_unary(remove_method_desc) - assert remove_request.context == MISSION_ID + assert remove_request.context == data_pb2.CONTEXT_MISSIONS assert remove_request.collection == collection assert remove_request.record_id == record_id @@ -990,7 +987,7 @@ def test_remove_collection_success( remove_future = thread_pool.submit(asyncio.run, client.remove_collection(collection)) _, remove_request, remove_rpc = test_channel.take_unary_unary(remove_coll_method_desc) - assert remove_request.context == MISSION_ID + assert remove_request.context == data_pb2.CONTEXT_MISSIONS assert remove_request.collection == collection remove_context = FakeContext() @@ -1166,7 +1163,7 @@ def test_list_records_success( list_future = thread_pool.submit(asyncio.run, client.list(collection)) _, list_request, list_rpc = test_channel.take_unary_unary(list_method_desc) - assert list_request.context == MISSION_ID + assert list_request.context == data_pb2.CONTEXT_MISSIONS assert list_request.collection == collection list_context = FakeContext() @@ -1180,6 +1177,48 @@ def test_list_records_success( values = sorted([r.data.value for r in results]) assert values == [100, 200, 300] + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.smoke + def test_list_cross_owner_context_and_visibilities( + self, + client: GrpcStorage, + test_channel: grpc_testing.Channel, + mock_servicer: MockStorageServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """List under USERS/ORGANIZATIONS maps to the cross-owner wire enum. + + Verifies: + - context=USERS -> CONTEXT_USERS, context=ORGANIZATIONS -> CONTEXT_ORGANIZATIONS + - the visibilities filter is forwarded on the wire + """ + collection = "test_collection" + list_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ + "ListRecords" + ] + + for scope_context, wire in ( + (ContextStorage.USERS, data_pb2.CONTEXT_USERS), + (ContextStorage.ORGANIZATIONS, data_pb2.CONTEXT_ORGANIZATIONS), + (ContextStorage.UNSPECIFIED, data_pb2.CONTEXT_UNSPECIFIED), + ): + list_future = thread_pool.submit( + asyncio.run, + client.list(collection, context=scope_context, visibilities=[Visibility.PUBLIC, Visibility.INTERNAL]), + ) + _, list_request, list_rpc = test_channel.take_unary_unary(list_method_desc) + + assert list_request.context == wire + assert list_request.collection == collection + assert list(list_request.visibilities) == [data_pb2.VISIBILITY_PUBLIC, data_pb2.VISIBILITY_INTERNAL] + + list_context = FakeContext() + list_response = mock_servicer.ListRecords(list_request, list_context) + list_rpc.send_initial_metadata(()) + list_rpc.terminate(list_response, (), grpc.StatusCode.OK, "") + assert isinstance(list_future.result(timeout=1.0), list) + @pytest.mark.grpc @pytest.mark.integration @pytest.mark.edge_case @@ -1270,6 +1309,39 @@ def test_list_records_multiple_collections( assert results[0].collection == "test_collection" assert results[0].data.name == "Collection 1" + @pytest.mark.grpc + @pytest.mark.edge_case + async def test_list_skips_invalid_records(self, client: GrpcStorage) -> None: + """Test that a record failing schema validation is skipped, not the whole list. + + Verifies: + - Records written by other modules with a foreign shape do not empty the list + - Valid records in the same collection are still returned + """ + from unittest.mock import AsyncMock + + from google.protobuf.struct_pb2 import Struct + + def _record(record_id: str, data: dict) -> data_pb2.StorageRecord: + struct = Struct() + struct.update(data) + return data_pb2.StorageRecord( + context=MISSION_ID, + collection="test_collection", + record_id=record_id, + data=struct, + data_type=data_pb2.DataType.Value("OUTPUT"), + ) + + valid = _record("valid", {"mission_id": MISSION_ID, "name": "ok", "value": 1}) + invalid = _record("foreign", {"unexpected": "shape"}) + client.exec_grpc_query = AsyncMock( # type: ignore[method-assign] + return_value=data_pb2.ListRecordsResponse(records=[invalid, valid]) + ) + + results = await client._list("test_collection", MISSION_ID) + assert [r.record_id for r in results] == ["valid"] + class TestStorageEdgeCases: """Tests for edge cases and error handling. @@ -1358,7 +1430,7 @@ def test_store_record_with_large_data( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.edge_case - def test_mission_isolation( + def test_mission_context_kind_only( self, test_channel: grpc_testing.Channel, storage_config: dict[str, type[BaseModel]], @@ -1366,13 +1438,12 @@ def test_mission_isolation( dummy_client_config: ClientConfig, thread_pool: futures.ThreadPoolExecutor, ) -> None: - """Test that records from different missions are isolated. + """Requests carry only the context KIND — never the concrete mission id. - Verifies: - - Records are isolated by mission_id - - One mission cannot access another mission's records + Since dev4 the concrete id travels via x-mission-id task metadata and + isolation is enforced server-side; two clients with different mission ids + must emit byte-identical context fields. """ - # Create two clients with different mission IDs mission1_id = "missions:mission_1" mission2_id = "missions:mission_2" @@ -1383,58 +1454,31 @@ def test_mission_isolation( client2.stub = AsyncStubWrapper(storage_service_pb2_grpc.StorageServiceStub(test_channel)) collection = "test_collection" - record_id = "shared_record_id" store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ "StoreRecord" ] - read_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "ReadRecord" - ] - # Store with client1 data1 = {"mission_id": mission1_id, "name": "Mission 1 Data", "value": 100} - store_future1 = thread_pool.submit(asyncio.run, client1.store(collection, record_id, data1)) + store_future1 = thread_pool.submit(asyncio.run, client1.store(collection, "record_1", data1)) _, store_request1, store_rpc1 = test_channel.take_unary_unary(store_method_desc) - store_context1 = FakeContext() - store_response1 = mock_servicer.StoreRecord(store_request1, store_context1) + store_response1 = mock_servicer.StoreRecord(store_request1, FakeContext()) store_rpc1.send_initial_metadata(()) store_rpc1.terminate(store_response1, (), grpc.StatusCode.OK, "") result1 = store_future1.result(timeout=1.0) - # Store with client2 data2 = {"mission_id": mission2_id, "name": "Mission 2 Data", "value": 200} - store_future2 = thread_pool.submit(asyncio.run, client2.store(collection, record_id, data2)) + store_future2 = thread_pool.submit(asyncio.run, client2.store(collection, "record_2", data2)) _, store_request2, store_rpc2 = test_channel.take_unary_unary(store_method_desc) - store_context2 = FakeContext() - store_response2 = mock_servicer.StoreRecord(store_request2, store_context2) + store_response2 = mock_servicer.StoreRecord(store_request2, FakeContext()) store_rpc2.send_initial_metadata(()) store_rpc2.terminate(store_response2, (), grpc.StatusCode.OK, "") result2 = store_future2.result(timeout=1.0) - # Read with client1 - read_future1 = thread_pool.submit(asyncio.run, client1.read(collection, record_id)) - _, read_request1, read_rpc1 = test_channel.take_unary_unary(read_method_desc) - read_context1 = FakeContext() - read_response1 = mock_servicer.ReadRecord(read_request1, read_context1) - read_rpc1.send_initial_metadata(()) - read_rpc1.terminate(read_response1, (), grpc.StatusCode.OK, "") - read_result1 = read_future1.result(timeout=1.0) - - # Read with client2 - read_future2 = thread_pool.submit(asyncio.run, client2.read(collection, record_id)) - _, read_request2, read_rpc2 = test_channel.take_unary_unary(read_method_desc) - read_context2 = FakeContext() - read_response2 = mock_servicer.ReadRecord(read_request2, read_context2) - read_rpc2.send_initial_metadata(()) - read_rpc2.terminate(read_response2, (), grpc.StatusCode.OK, "") - read_result2 = read_future2.result(timeout=1.0) - - # Verify isolation + assert store_request1.context == data_pb2.CONTEXT_MISSIONS + assert store_request2.context == data_pb2.CONTEXT_MISSIONS assert result1.data.value == 100 assert result2.data.value == 200 - assert read_result1.data.name == "Mission 1 Data" - assert read_result2.data.name == "Mission 2 Data" @pytest.mark.grpc @pytest.mark.integration diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index c8f4de96..8e9eff23 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -80,8 +80,8 @@ class TestCauseChaining: async def test_default_setup_wraps_validation_error(self) -> None: setup = DefaultSetup() - with pytest.raises(SetupServiceError) as ei: - await setup.create_setup_version({"data": {}, "setup_id": "s1"}) + with pytest.raises(ValueError, match="Validation failed for SetupData") as ei: + await setup.create_setup({"name": "n", "content": "not-a-dict"}) assert isinstance(ei.value.__cause__, ValidationError) def test_get_trigger_wraps_stop_iteration(self) -> None: diff --git a/uv.lock b/uv.lock index f8671d89..a4cb36e5 100644 --- a/uv.lock +++ b/uv.lock @@ -23,7 +23,7 @@ wheels = [ [[package]] name = "agentic-mesh-protocol" -version = "1.0.0b0" +version = "1.0.1.dev4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bump-my-version" }, @@ -33,9 +33,9 @@ dependencies = [ { name = "protobuf" }, { name = "protovalidate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/05/c5098beff80d3f12ab5708fe04e9b6712181b5964c1fefc69dc24cc12d13/agentic_mesh_protocol-1.0.0b0.tar.gz", hash = "sha256:101a87cd54bf08c3c85c293d4772154235748da2cc6e4b5dca4ba0561cae26d9", size = 74748, upload-time = "2026-07-15T08:08:39.886Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/70/8b857e3af62dad30b09e31cbe41a45e571c691a64fbd16368e336b59df75/agentic_mesh_protocol-1.0.1.dev4.tar.gz", hash = "sha256:5e05a8a71121f9ffb9122636a0cd6fd65b195aa6b76e1cbd397829cd6a4e0552", size = 74993, upload-time = "2026-08-04T12:03:55.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/fb/792ba6f9c98866b348406bf5ac841a0e9ced03d68de929b15293ab3d6e97/agentic_mesh_protocol-1.0.0b0-py3-none-any.whl", hash = "sha256:046d50cb720ac4cd875fa4c96629f8e484df86881d327a622e8c63c1fbc97141", size = 103914, upload-time = "2026-07-15T08:08:38.442Z" }, + { url = "https://files.pythonhosted.org/packages/10/0f/3da3dbcd044e01ad7eb6719c6df4f1f812366cf0f482e739dff75ecef49b/agentic_mesh_protocol-1.0.1.dev4-py3-none-any.whl", hash = "sha256:9028570290ad7311f8610619ac9e7f4bd9ce1e5d14117430748ad470ff8e8284", size = 104819, upload-time = "2026-08-04T12:03:53.568Z" }, ] [[package]] @@ -94,16 +94,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.2" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] [[package]] @@ -211,11 +211,11 @@ wheels = [ [[package]] name = "bracex" -version = "3.0.1" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4", size = 44019, upload-time = "2026-07-20T13:43:00.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/f5/4473ad9b48cd0420a2d762a3750fa0e078e23e060b1af72662e5987e5530/bracex-3.0.tar.gz", hash = "sha256:b73f718d6bd98d8419e45df02426c86e9967c179949f779340d6c3a8c83b9111", size = 43162, upload-time = "2026-06-30T00:43:35.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c", size = 11940, upload-time = "2026-07-20T13:42:59.268Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2e/68781b78e764e5ccc4af1e3d27e060069c73af90234853fa80000e7ee79d/bracex-3.0-py3-none-any.whl", hash = "sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5", size = 11738, upload-time = "2026-06-30T00:43:34.196Z" }, ] [[package]] @@ -310,112 +310,84 @@ wheels = [ [[package]] name = "cffi" -version = "2.1.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, - { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, - { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, - { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, - { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, - { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, - { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, - { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, - { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, - { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, - { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, - { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, - { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, - { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, - { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, - { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, - { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, - { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, - { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, - { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, - { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, - { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, - { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, - { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, - { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, - { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, - { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, - { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, - { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, - { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, - { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, - { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, - { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -429,89 +401,107 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, - { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, - { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, - { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, - { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, - { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, - { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, - { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, - { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, - { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, - { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, - { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, - { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, - { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, - { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, - { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, - { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, - { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, - { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -537,100 +527,100 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, - { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, - { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, - { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, - { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, - { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, - { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, - { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, - { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, - { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, - { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, - { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, - { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, - { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, - { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, - { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, - { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, - { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, - { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, - { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, - { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, - { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, - { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, - { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, - { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, - { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, - { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, - { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, - { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bd/b01188f0de73ee8b6597cf20c63fccd898ad31405772f15165cb61a62c00/coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e", size = 220378, upload-time = "2026-06-22T23:07:38.925Z" }, + { url = "https://files.pythonhosted.org/packages/33/eb/f7aa3cb46500b709070c8d12335446971ec8b8c2ea155fea05d2000b4b1f/coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d", size = 220895, upload-time = "2026-06-22T23:07:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/b41b8499fc9060ca40ad2a197d301155be1ead398f0f0bfdb27b2b4a660f/coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c", size = 247631, upload-time = "2026-06-22T23:07:43.244Z" }, + { url = "https://files.pythonhosted.org/packages/da/bb/e9ecea1307c6a549c223842cccbd5d55193cc27b82f26338782d4355047c/coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e", size = 249460, upload-time = "2026-06-22T23:07:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/59/cb/3821542809b7b726296fd364ed1c23d10a5770f1469957010c3b4bc5d408/coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610", size = 251324, upload-time = "2026-06-22T23:07:46.875Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/f34f66f0ff152189ccc7b3f0582cf7909e239cb3b8c214362ed2149719b8/coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137", size = 253237, upload-time = "2026-06-22T23:07:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/22/81/aa363fa95d14fc892bd5de80edadc8d7cce584a0f6376f6336e492618e67/coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18", size = 248344, upload-time = "2026-06-22T23:07:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/dc8a149441a3fea611cbbaf46bb12099adbe08f69903df1794581b0504b8/coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647", size = 249365, upload-time = "2026-06-22T23:07:51.464Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a2/0004127deee122e020be24a4d86ce72fa14ae28198811b945aabf91293b5/coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803", size = 247369, upload-time = "2026-06-22T23:07:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/1e/72/3654c004f4df4f0c5a9643d9abaed5b26e5d3c1d0ecabe788786cb425efa/coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27", size = 251182, upload-time = "2026-06-22T23:07:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2f/7bdcdf1e7c4d0632648852768063c25582a0a747bb5f8036a04e211e7eb7/coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640", size = 247639, upload-time = "2026-06-22T23:07:56.254Z" }, + { url = "https://files.pythonhosted.org/packages/03/dc/0e01b071f69021d262a51ce39345dd6bc194465db0acfc7b34fd89e6b787/coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7", size = 248242, upload-time = "2026-06-22T23:07:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/1c/51/08279e6ebe3479bf705db5fdc1a968e44ba1567e4cbc567f76b45f5e646e/coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b", size = 222431, upload-time = "2026-06-22T23:07:59.094Z" }, + { url = "https://files.pythonhosted.org/packages/40/2f/5c56670781fee5722ef0c415a74750c9a033bfacdb9d07b1493a0308108d/coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61", size = 223059, upload-time = "2026-06-22T23:08:00.662Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, + { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, + { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, + { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, ] [package.optional-dependencies] @@ -725,7 +715,7 @@ wheels = [ [[package]] name = "digitalkin" -version = "1.0.1.dev0" +version = "1.0.2.dev7" source = { editable = "." } dependencies = [ { name = "ag-ui-protocol" }, @@ -812,12 +802,12 @@ tests = [ [package.metadata] requires-dist = [ { name = "ag-ui-protocol", specifier = ">=0.1.18" }, - { name = "agentic-mesh-protocol", specifier = "==1.0.0b0" }, + { name = "agentic-mesh-protocol", specifier = "==1.0.1.dev4" }, { name = "agno", marker = "extra == 'agno'", specifier = ">=2.6" }, { name = "anyio", specifier = ">=4.13.0" }, - { name = "grpcio-health-checking", specifier = "==1.81.0" }, - { name = "grpcio-reflection", specifier = "==1.81.0" }, - { name = "grpcio-status", specifier = "==1.81.0" }, + { name = "grpcio-health-checking", specifier = "==1.82.1" }, + { name = "grpcio-reflection", specifier = "==1.82.1" }, + { name = "grpcio-status", specifier = "==1.82.1" }, { name = "pydantic", specifier = ">=2.12.4" }, { name = "pydantic-settings", specifier = ">=2.14.1" }, { name = "pyinstrument", marker = "extra == 'profiling'", specifier = ">=5.1.2" }, @@ -838,7 +828,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.411" }, { name = "ruff", specifier = ">=0.15.20" }, { name = "twine", specifier = ">=6.2.0" }, - { name = "types-grpcio", specifier = ">=1.0.0.20260518" }, + { name = "types-grpcio", specifier = ">=1.82.1.20260711" }, { name = "types-grpcio-health-checking", specifier = ">=1.0.0.20260518" }, { name = "types-grpcio-reflection", specifier = ">=1.0.0.20260508" }, { name = "types-protobuf", specifier = ">=7.34.1.20260518" }, @@ -917,7 +907,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -945,11 +935,11 @@ lua = [ [[package]] name = "filelock" -version = "3.31.2" +version = "3.29.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/2c/c603f8aedff281295f7afce455d5c05f33459c7bf684abb46228f844a1f0/filelock-3.31.2.tar.gz", hash = "sha256:e6d35965c709527915a184837a8421826d18bc3f9d7e9a5a0c8114a782475d66", size = 200476, upload-time = "2026-07-21T04:04:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/4c/c0ec6b645dcfefdd158313edb68c676d36c3f93ce8d2d9f0725d6341e506/filelock-3.31.2-py3-none-any.whl", hash = "sha256:18a4179901809ad1905c6631d907f1c3a41806c0d3506f7f9862565576a81a16", size = 97486, upload-time = "2026-07-21T04:04:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, ] [[package]] @@ -990,14 +980,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.53" +version = "3.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/24/0e0c12cb6f7cb864779a9d2fefee9ca91838f6db402c8780c9d28a8d7ebe/gitpython-3.1.53.tar.gz", hash = "sha256:06ae8d9623b0ed0d67b8adeac5c7008d0a5a404b087a9e0d0c7163bdd3a6b497", size = 224597, upload-time = "2026-07-20T13:41:52.839Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/a6/bff12b3238885eeef7d28ef908b24e0cba91c476c31cb876a00a0986ce2c/gitpython-3.1.53-py3-none-any.whl", hash = "sha256:187885556b64ab357bd4ea84e2c4cce2861a613a7f4268b3f7f7ba05f2ce4ab0", size = 216237, upload-time = "2026-07-20T13:41:51.473Z" }, + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] [[package]] @@ -1097,179 +1087,179 @@ wheels = [ [[package]] name = "grpcio" -version = "1.81.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/f3/23f47b24f8d8c2028eba501db3acfbb2f592cbb5995eaa6e363a627b74d7/grpcio-1.81.0.tar.gz", hash = "sha256:a5acd7efd3b1fe9b4eb0bcaaa1507eed68a0ad0678b654c3f7b464df9ba9dca5", size = 13032272, upload-time = "2026-06-01T05:56:22.827Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/a0/13f7dd9602a44c2852eb5ca29dfcb14de5547e1d37672dbf20e3cf17d5d2/grpcio-1.81.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:b4108e5d9d0f651b7eea749116181fe6c315b145661a80ec31f05ec2dbe21af7", size = 6087534, upload-time = "2026-06-01T05:54:04.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/8a/439070efa430b3c51c8e319b67521957688905f27b294302c6077e9d4ef5/grpcio-1.81.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:b76ea9d55cd08fcdbda25d28e0f76679536710acb7fbd5b1f70cb4ac49317265", size = 12062452, upload-time = "2026-06-01T05:54:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6f/7802953eb46ab7082f70a139dac02a5544e8b784c4647f9750af28f64348/grpcio-1.81.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4e032feb3bfb4e2749b140a2302a6baa8ead1b9781ff5cf7094e4402b5e9372e", size = 6635199, upload-time = "2026-06-01T05:54:12.739Z" }, - { url = "https://files.pythonhosted.org/packages/09/33/91d7fd2392923407fc89e7f1493011dacd3f1a6972cff5fa2237ac1efd5d/grpcio-1.81.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:725801c7086d7e4cd160e42bb2f54e0aeb976b9568df3cc6f843b15d29b79fb1", size = 7333482, upload-time = "2026-06-01T05:54:15.474Z" }, - { url = "https://files.pythonhosted.org/packages/9a/df/ec0a4e04472df2618f8741151fa026bc877648e952ebb0e421169e0b992b/grpcio-1.81.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f750a091fff3a3991731abc1f818bdc64874bb3528162732cb4d45f2e07821a6", size = 6837709, upload-time = "2026-06-01T05:54:18.036Z" }, - { url = "https://files.pythonhosted.org/packages/86/82/9f69147bbd723ff07fea0242e5877a9026be1819410996e6086aae8f00a6/grpcio-1.81.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8226ba097eed660ef14d36c6a69b85038552bb8b6d17b44a5aa6f9abf48b8e08", size = 7440601, upload-time = "2026-06-01T05:54:20.662Z" }, - { url = "https://files.pythonhosted.org/packages/89/3b/52c1558e94941022b7ee046583fe4a007164c7e18087d55f82fd23c567b8/grpcio-1.81.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:40edffb4ec3689373825d367c4457727047a6e554f03245265ecc8cc03215f22", size = 8442803, upload-time = "2026-06-01T05:54:22.941Z" }, - { url = "https://files.pythonhosted.org/packages/4a/5d/1264d086c5d3cc81c59084de1ccc87d1a037f91ce9cb1f611caaa19b70cc/grpcio-1.81.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f85570a016d794c29b1e76cf22f67af4486ddbe779e0f30674f138fa4e1769ec", size = 7868964, upload-time = "2026-06-01T05:54:25.627Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b4/3b3339e661669d545f09ee7ea33fec3b1b438e623b3105597d3457c39391/grpcio-1.81.0-cp310-cp310-win32.whl", hash = "sha256:3755c9669307cad18e7e009860fdea98118978d2300451bd8530a53048e741e7", size = 4202292, upload-time = "2026-06-01T05:54:28.261Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c3/cd81087855dfd4bbef2db50e58e1f7ce93a9a1675bc89a6cb76aa438ffaa/grpcio-1.81.0-cp310-cp310-win_amd64.whl", hash = "sha256:909bb3222b53235498d2c5817a0596d82b0aaea490ba93fdf1b060e2938a543c", size = 4937038, upload-time = "2026-06-01T05:54:30.376Z" }, - { url = "https://files.pythonhosted.org/packages/45/a8/9916ab10a0201f4c7afb6918125aa2f38a7626ee18ffbc066dd9cb04a74d/grpcio-1.81.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:794e6aa648e8df47d8f908dc8c3b42347d04ec58438f1dcd4e445f09b4f6b0ce", size = 6093557, upload-time = "2026-06-01T05:54:32.64Z" }, - { url = "https://files.pythonhosted.org/packages/a7/43/99e969a048904a65df3129ee53c5f523b7c4e43127786460cac4bee82470/grpcio-1.81.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cd78145b7f7784661c524624f3526c9c6f891b30a4b54cb93a40806d0d0d61e9", size = 12075345, upload-time = "2026-06-01T05:54:35.77Z" }, - { url = "https://files.pythonhosted.org/packages/83/70/4c3a204e190333768d4f63f4ff56bd0bf405f05b9188f3a59a8bcf161f8b/grpcio-1.81.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:638ccc1b86f7540170a169cb900799b9296a1381e47879ce60b0de9d3db73d33", size = 6640664, upload-time = "2026-06-01T05:54:38.854Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a9/0fa17ac8b4e29cf59b26915be6cab8c0d4583ce24a6208a287b6e5f6d072/grpcio-1.81.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:21ec30b9ea320c8207ea7cd05873ad64aa69fdd0e81b6758b3347983ba20b50a", size = 7332542, upload-time = "2026-06-01T05:54:41.39Z" }, - { url = "https://files.pythonhosted.org/packages/f4/18/7c8e3d0dda2fb7a17076fcd6c9085209eabad3354696c64230f87b3a14eb/grpcio-1.81.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dbdb99986548a7e87f8343805ef315fd4eb50ffaabf4fb1206e42f2542bb805d", size = 6842564, upload-time = "2026-06-01T05:54:43.57Z" }, - { url = "https://files.pythonhosted.org/packages/f6/19/2f1726c2e03ad3f3fe241e6b41534532ad580d595de14a4054ad84999c80/grpcio-1.81.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c36f5d5e97944cbda2d4096b4ae262e6e68506246b61582acf1b8591607f3ccc", size = 7446236, upload-time = "2026-06-01T05:54:46.042Z" }, - { url = "https://files.pythonhosted.org/packages/a7/dc/0321f892212e2c0bfe248cea24c00d7d7111639688ec5ffd8e36b5c02fe6/grpcio-1.81.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9f355384e5543ab77a755a7085225ecc19f32b76032e851cbd8145715d79dec8", size = 8445633, upload-time = "2026-06-01T05:54:48.809Z" }, - { url = "https://files.pythonhosted.org/packages/e5/20/0e7ea7494955cf1beea3077b2fd2c04c84d4480c2ae85a1e1cfa150c62d7/grpcio-1.81.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:77eb4e9fe61486bd1198cc7236ebb0f70e66234e63c0348f40bc2553ed16a88b", size = 7873958, upload-time = "2026-06-01T05:54:52.135Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/6438e226046c2a0778060e2b1d791a4827277bbd9d223013c2c63ee7435e/grpcio-1.81.0-cp311-cp311-win32.whl", hash = "sha256:7915a2e63acdc05264a206e1bddfd8e1fb8a29e406c18d72d30f8c124e021374", size = 4202110, upload-time = "2026-06-01T05:54:54.134Z" }, - { url = "https://files.pythonhosted.org/packages/42/6b/d0895e93d65b186f5f1737fcc186d7faa487e2d9d934eda111a37a309869/grpcio-1.81.0-cp311-cp311-win_amd64.whl", hash = "sha256:5e925a70fe99fe5794f7beca0ea034c75f068afcc356d79047e73f99cdcca34c", size = 4940942, upload-time = "2026-06-01T05:54:56.749Z" }, - { url = "https://files.pythonhosted.org/packages/82/d5/896a3aaf07068d707d88b282a04914b872db4d32d3c7e6d88e43a3b911fa/grpcio-1.81.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:57b3b0e73a518fa286959b40c3eddd02703504ca186e8b7b2945954519bd8b2c", size = 6053538, upload-time = "2026-06-01T05:54:58.965Z" }, - { url = "https://files.pythonhosted.org/packages/68/6a/7e3eafa4727cd405ff917605ed2949e2af162f233f5cbdd773723a5fea7d/grpcio-1.81.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8bb1789c94322a13336a2b6c58d9c14d68f8628b6e24205a799c69f5bf8516ce", size = 12053447, upload-time = "2026-06-01T05:55:01.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/79/a4302aa82428de48a922421f522b027a1a727ab4d0926368454aa953d36d/grpcio-1.81.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e4d053900a0d24b75d7521139a3872150301b3d6bde3bed5e12318fb25791e4d", size = 6595872, upload-time = "2026-06-01T05:55:04.946Z" }, - { url = "https://files.pythonhosted.org/packages/b4/1f/7ff2850eaefbecf99af3f624dbb28dd1ad6c5fd4c1d8c26909ed6482673b/grpcio-1.81.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:db217c2e52931719f9937bd12082cd4d7b495b35803d5760686975c285924bf8", size = 7303857, upload-time = "2026-06-01T05:55:07.205Z" }, - { url = "https://files.pythonhosted.org/packages/e2/98/1f3896a9baae1f2aedf4e99c55291d6fa1f30ad9603d63bc18bda967b53e/grpcio-1.81.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19f201da7b4e5c0559198abe5a97157e726f3abe6e8f5e832d4a50740f6dcc22", size = 6809676, upload-time = "2026-06-01T05:55:09.513Z" }, - { url = "https://files.pythonhosted.org/packages/34/8b/3441983718095208c5d797fd3239882e97ea89a629f41c8df94b4eef4df9/grpcio-1.81.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:275144b0115353339dbb8a6f28a9cf8997b5bf40e37f8f66ac0b0ea57e95b43f", size = 7412654, upload-time = "2026-06-01T05:55:12.777Z" }, - { url = "https://files.pythonhosted.org/packages/3c/98/1eddf07df6e4fe85cf67502a793f7b05468b2dca3d1ef35b972cf5d54468/grpcio-1.81.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5192857589f223e5a98ff0e31f6e551b19040e647d17bfe10116c8a2ce3b8696", size = 8408026, upload-time = "2026-06-01T05:55:15.514Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/3860341e6a1f5347be6ab35c6c0e1e3a8eb59d010388207fd561dcf01a88/grpcio-1.81.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6ff087cb1f563f47b504b4e29e684129fc5ae4863faf3ebca08a327764ee6cb", size = 7849498, upload-time = "2026-06-01T05:55:18.078Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3f/0ea06bd85c701966aa3f8f37314f2ed83520d2b7590f42d643d445d8bc8b/grpcio-1.81.0-cp312-cp312-win32.whl", hash = "sha256:98c6240f563178fc5877bd50e6ff274463e53e1472128f4110742450739659fa", size = 4184161, upload-time = "2026-06-01T05:55:20.127Z" }, - { url = "https://files.pythonhosted.org/packages/39/e3/a7c387406827a86f99ad7838b995bf9b4a182ffe2d2c439ed2873efec952/grpcio-1.81.0-cp312-cp312-win_amd64.whl", hash = "sha256:87e33b7afcfb3585121b5f007d2c52b8c534104d18f556e840d35193ca2a9141", size = 4929958, upload-time = "2026-06-01T05:55:22.736Z" }, - { url = "https://files.pythonhosted.org/packages/f3/29/779ee53c931d0fd55c1d459fde43e485172caa3ac87cbd43d003a13a0185/grpcio-1.81.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:62bbe463c9f0f2ff24e31bd25f8dd8b4bae78900e315915a3195a0ef1471a855", size = 6054973, upload-time = "2026-06-01T05:55:25.043Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b6/7211807926b5a17f8d9a5d47c739a163d6812fefe3e4714e81cf92945ed7/grpcio-1.81.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43c121e135ae44d1559b430db2b2dfad7421cbbe40e1deba506c7dc62b439719", size = 12048662, upload-time = "2026-06-01T05:55:28.453Z" }, - { url = "https://files.pythonhosted.org/packages/64/89/b1b93ef6b34bd20bbaf707fa99133bc9cc302139d5ec6f77a165c7169796/grpcio-1.81.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f345de40ef2e65f63645d53d251824e6070e07804827c5b00ec2e44555f9f901", size = 6599116, upload-time = "2026-06-01T05:55:31.185Z" }, - { url = "https://files.pythonhosted.org/packages/eb/bc/c89f9b9d1c22895715356a1e009554dae66319e97826bb4d30bcda7d29e8/grpcio-1.81.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8c0855a350886f713b9e458e2a10d208009dcaa849f574e39cd6067db1fe1279", size = 7307591, upload-time = "2026-06-01T05:55:33.463Z" }, - { url = "https://files.pythonhosted.org/packages/65/4a/1df2a4cb4a1386e066ab7e4175e34bb884b35ccb60d3621c09c84af6aabb/grpcio-1.81.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a524cd530900bd24511fcb7f2ed144da4ea37711c4b094475d0bceca7a93a170", size = 6811797, upload-time = "2026-06-01T05:55:36.731Z" }, - { url = "https://files.pythonhosted.org/packages/8d/dc/fa189d20601a1be25b08850cfb733879bbb1047b62a8feec3a60e3e1a87b/grpcio-1.81.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e7746ba3e6efc9e2b748eff59470a2b8684d5a9ec607c6580bcaa5be175820bc", size = 7415131, upload-time = "2026-06-01T05:55:39.451Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a3/5625c48cb48d23c6631b3e5294f88e4c751f22a52591ae78859fab96dca1/grpcio-1.81.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:aaaa4f7f2057d795952e4eacf3f342be8b5b156992f6ac85023c8b98794ebd47", size = 8408398, upload-time = "2026-06-01T05:55:42.219Z" }, - { url = "https://files.pythonhosted.org/packages/75/34/0f8202c6809a46c2b4d69125ef3667c40b1c211f8e19930e5fa1f1197039/grpcio-1.81.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fba53cb96004b2b7fb758b46b2288cb49d0b658316a4e73f3ef67230616ee65", size = 7844481, upload-time = "2026-06-01T05:55:44.849Z" }, - { url = "https://files.pythonhosted.org/packages/c0/95/c3366b5b5edf4c4adc90f2e29ca16e57965a8e56dc8d2ee89565ba1905bb/grpcio-1.81.0-cp313-cp313-win32.whl", hash = "sha256:c197e2ef75a442528072b29e9755da299110e8610e8bcbb59a6b4cf55384f005", size = 4182777, upload-time = "2026-06-01T05:55:47.459Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a7/932f2f748511a32e641a2aba0d30dded3ed6e8bc330e0924e4d5d86853e6/grpcio-1.81.0-cp313-cp313-win_amd64.whl", hash = "sha256:194eddfacc84d80f50512e9fd4ee851d5f2499f18f299c95aa8fb4748f0537e0", size = 4928085, upload-time = "2026-06-01T05:55:50.158Z" }, - { url = "https://files.pythonhosted.org/packages/c5/1d/28b231333857deb840bc3d182ae087510170ea6d68f21393aeb0fe499530/grpcio-1.81.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:a9351055f52660b58f3d4890ea66188b5134399f82b11aa0c55bd4b99eff5390", size = 6055712, upload-time = "2026-06-01T05:55:52.889Z" }, - { url = "https://files.pythonhosted.org/packages/e8/b8/999c14f9dff0fc47549d2e827cba1343ddc18e1d1bf0d06d2cf628eecbd9/grpcio-1.81.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:300f3337b6425fd16ead9a4f9b2ac25801acb64aa5bc0b99eb69901645b2b1d2", size = 12057189, upload-time = "2026-06-01T05:55:55.952Z" }, - { url = "https://files.pythonhosted.org/packages/1e/3d/1fbde079572562af65351151d840525a13879eb7b481d35b55cd64c6127a/grpcio-1.81.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:97bbd623f7ded558fd4f7cb5a4f600c4d4de65c5dd364c83a5b14b2a10a2d3b5", size = 6608136, upload-time = "2026-06-01T05:55:59.069Z" }, - { url = "https://files.pythonhosted.org/packages/32/89/1f17cb6882abfd8e5a303a25d5d1665abef5a8c499a96198c65a651d1b85/grpcio-1.81.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff83d889e3ebf6341c8c7864ad8031591ad5ca61599072fc511644d1eb962d2b", size = 7307045, upload-time = "2026-06-01T05:56:02.376Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/f98e91b2e755652e637ea2144318b0229b290062199f761b445fe1fa6015/grpcio-1.81.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c4fe218c5a35e1d87a5a26544237f1fa41dfd9cbd3c856b0810a30061f8b0aaf", size = 6812794, upload-time = "2026-06-01T05:56:05.777Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0c/77892d715ac41e7ec0ace2a50080ffb64e189188056f607a66fe0014d1ee/grpcio-1.81.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b8b025b6af43ee0ad4a70307025d77bcab5adde7c4597786010d802c203e9fc5", size = 7422767, upload-time = "2026-06-01T05:56:08.524Z" }, - { url = "https://files.pythonhosted.org/packages/3f/b8/aa04590c6564714d94954515f15a236e59d4b9b3ad01e615f1b706d7792d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3d4e0ce5a40a998cf608c8ba60ecfe18fdf364a9aa193ae4ac3faeecd0e86757", size = 8408551, upload-time = "2026-06-01T05:56:11.283Z" }, - { url = "https://files.pythonhosted.org/packages/43/3d/4f4a3450a1973568910c6909cb74abbf2126f68aefae5976962f9f7ad50d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa948712c8e5fa40ec250870bda14bc7578e1bb832a8912d9d2a0f720518edbe", size = 7846468, upload-time = "2026-06-01T05:56:14.536Z" }, - { url = "https://files.pythonhosted.org/packages/88/f4/5827fd248221ad3b44161c23ce9b5f4ee405b04fc6da5fd402a9aa87a84a/grpcio-1.81.0-cp314-cp314-win32.whl", hash = "sha256:fbbe81314a9d92156abce8b62c09364eb8bafc0ca2a19919a45ec64b5c6cb664", size = 4264427, upload-time = "2026-06-01T05:56:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/127dc2b246096ad50ef7c8d9b7b31d757787aeb796368bcdd4454e4204c4/grpcio-1.81.0-cp314-cp314-win_amd64.whl", hash = "sha256:b93cee313cae4e113fbb3a0ce1ea5633db6f63cfde2b2dc1d817429026b2a50b", size = 5070848, upload-time = "2026-06-01T05:56:19.735Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/14/5d05bfd85c101cbe44a12d7c1cea9c40698e0438cddf3a70019f735b5a27/grpcio-1.82.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:91859d1cac5f47caec5fc40e9f827500cdb54ce5b36450dc9a65616b5af49c17", size = 6177087, upload-time = "2026-07-08T12:34:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/19/2e/c906f8e6d0b54c0137885fff6f7b5883c6bbc381b44a0ba5ea07d7d1579b/grpcio-1.82.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c80c9741dcef192f669876a81957cf7713b441c2f0c43631350d75fa49321d31", size = 11960907, upload-time = "2026-07-08T12:34:10.583Z" }, + { url = "https://files.pythonhosted.org/packages/de/be/ec4aa76cdf25539b9e960cbb9d5739f892ea6cde58078b5293860c1159d3/grpcio-1.82.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b89cff456796d2f0581783726ad017a2c70aff2d27b0f05504c34e2e417f7560", size = 6754802, upload-time = "2026-07-08T12:34:13.082Z" }, + { url = "https://files.pythonhosted.org/packages/e6/dd/47519c2a8fd9db47ec4493f44bd9f5b0175307e07089b1132e54b7b5b19c/grpcio-1.82.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d6e8a08f7038ba7a77f71e250804e4aba84fe91d22cfc54ff43c07b7529c4728", size = 7484535, upload-time = "2026-07-08T12:34:15.164Z" }, + { url = "https://files.pythonhosted.org/packages/63/99/659711e9689c4dd553bcd4eacff9cb9f458f34b60edf7afb3bbc1b0a58a2/grpcio-1.82.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50fd2fe83426b1b1c6cdc4d72d555223b7dddf8ce07c5bac218b13fc6d684c6f", size = 6919066, upload-time = "2026-07-08T12:34:17.367Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/f2b772356b4f593ffe439795509fcbf675b0ff98211ae8ce2a180f2e559f/grpcio-1.82.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b758540a24d5394a9c578bf9f6126389f474b106ac3d9df1d53de56cb14c9fd9", size = 7525855, upload-time = "2026-07-08T12:34:19.479Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/b28cfffb989a84d8272593498bddd2d68148cce1813ad55189c469b0f1f8/grpcio-1.82.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c4ba4aac238f685743575d9d700003ac16537cce26e7c774993134f530652464", size = 8565122, upload-time = "2026-07-08T12:34:21.951Z" }, + { url = "https://files.pythonhosted.org/packages/97/f9/54956cb0c701190cbc9d7e535c3f84acf0285c6b9ed198a902766e17c3cd/grpcio-1.82.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed6fc621d6f366c88a60f0b971d5afd21d441d9aa561ee688de5b7acdb2cf901", size = 7933872, upload-time = "2026-07-08T12:34:24.539Z" }, + { url = "https://files.pythonhosted.org/packages/76/85/5f9cd1f965bbe4329556a212f178ae0c072b18b446cae05ed32fa8847c53/grpcio-1.82.1-cp310-cp310-win32.whl", hash = "sha256:bd2f45e46fff5b91c10997d0743a987517a7dde67c64c592835c2dcaac66f587", size = 4257373, upload-time = "2026-07-08T12:34:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/c4f42f7c69c53d27ed41643421b55908bcbe885b68f5a208135c72917c98/grpcio-1.82.1-cp310-cp310-win_amd64.whl", hash = "sha256:5e171d5f0d6a0af78ea7512783f170a44f80c165259d8773e3a354a7f991f2b5", size = 5006571, upload-time = "2026-07-08T12:34:28.778Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/e5092af97fa671ca279b3e373251af4bf87d5fbda7dc85f6a616899562a7/grpcio-1.82.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:0ddb18a9a9e1f46692b3567ae4abb3f8d117ce6afea48650f8eca06d8ab5d06f", size = 6181472, upload-time = "2026-07-08T12:34:31.009Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/18053a3a2ca03d0c2a1b8cc7271e705007a16aa5dae84bac00935c5b1a7f/grpcio-1.82.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cf855b1af246720f567b0ce5d0724d45dfa4188eecc3296a2a69257b11b9e94b", size = 11970995, upload-time = "2026-07-08T12:34:33.603Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/21b1acb052876ad00959ec4d1b05fe08607d650bcfa282073bb164c2703c/grpcio-1.82.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb30cb13e25bc13cea70ffc69d6d90c49d36ea6c1d4549e6912f70177834cac", size = 6760127, upload-time = "2026-07-08T12:34:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/25eef9c245c54f0061317d13a302357fe8ea03bac240b2b02ececcf54da4/grpcio-1.82.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1e822b2774f719c017cbe700b6e47173b6ae290fb84906f52a5a3c2c60b62e1e", size = 7484377, upload-time = "2026-07-08T12:34:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/1a348767eb9d9bd7765dc4fa8a01723d3bb386d67f981ee5c6f9c02b8b1c/grpcio-1.82.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5dafb1ece8ed45dee7c738f166ec82e19673221ed5ab8967f72858a4685345b2", size = 6924269, upload-time = "2026-07-08T12:34:40.583Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b9/3aae7a03d34c86ea27988db859a6087c186f6c3f53f9b551e07afd989bfa/grpcio-1.82.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06503106e7271e0a49fd5a1ac04747f1e47e87d900476db6fe45bc87ee411f4", size = 7531848, upload-time = "2026-07-08T12:34:43.277Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/3c4afa625d0dac9090707966916284c035fc5b2fb3e2c51e156accee6735/grpcio-1.82.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ff99bc8cafb6a952201c37b995f425e641c93ffa6e072258525feab57290141d", size = 8568217, upload-time = "2026-07-08T12:34:45.502Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d8489c628e73e20a3d034e7f66912de7b1acb405f01d388f056a88e47924/grpcio-1.82.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:644ae1b94266ac785330f4590a69e52b6a7eb73029043a02209db81c81397d69", size = 7938771, upload-time = "2026-07-08T12:34:48.323Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b7/0a92cfd1658f3a896d4aa12d4efeb7dd4ddfc723725ae22741a5241ea710/grpcio-1.82.1-cp311-cp311-win32.whl", hash = "sha256:e203d2e19d471630084a16c815616f8211dff21c268ab3c5f5bf38417832e074", size = 4256432, upload-time = "2026-07-08T12:34:50.432Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6a/2872c761b025d9ec74386f22a4a7d59c5a5b00ebf718761b33739ffc45de/grpcio-1.82.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d8299c285fe6cc6a1f56badf8d3bc5078c8d20273ee64bafa3783b4bc29a769", size = 5009633, upload-time = "2026-07-08T12:34:52.67Z" }, + { url = "https://files.pythonhosted.org/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58", size = 6144689, upload-time = "2026-07-08T12:34:55.567Z" }, + { url = "https://files.pythonhosted.org/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379", size = 11952034, upload-time = "2026-07-08T12:34:58.128Z" }, + { url = "https://files.pythonhosted.org/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f", size = 6710772, upload-time = "2026-07-08T12:35:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03", size = 7450677, upload-time = "2026-07-08T12:35:03.926Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9", size = 6886855, upload-time = "2026-07-08T12:35:06.174Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197", size = 7501323, upload-time = "2026-07-08T12:35:08.49Z" }, + { url = "https://files.pythonhosted.org/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0", size = 8536899, upload-time = "2026-07-08T12:35:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6", size = 7913892, upload-time = "2026-07-08T12:35:13.944Z" }, + { url = "https://files.pythonhosted.org/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438", size = 4240985, upload-time = "2026-07-08T12:35:16.048Z" }, + { url = "https://files.pythonhosted.org/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e", size = 5001580, upload-time = "2026-07-08T12:35:18.689Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, + { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, + { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, ] [[package]] name = "grpcio-health-checking" -version = "1.81.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/0d/9808962c92b3e003f44645d4331e547ae9c5a84919d58212d69ab26a9a10/grpcio_health_checking-1.81.0.tar.gz", hash = "sha256:09f31674f1acdcf214bc4e640ebbbbef165b077a1fd64834795196d52bfdce39", size = 17144, upload-time = "2026-06-01T06:00:34.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/64/060c857a962dae39cac69433f73145acd825b5348fad53908f3439a6fca8/grpcio_health_checking-1.82.1.tar.gz", hash = "sha256:86255e04e1d39f1c97a6632d41b63249351408de5c58e85eede87afd7d9828dd", size = 17130, upload-time = "2026-07-08T12:39:41.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/ea/ba9cd33a1bed529b7bd5986dd07ef604556220ea6e1289c7fed862db2393/grpcio_health_checking-1.81.0-py3-none-any.whl", hash = "sha256:1024304a85eecddb7a08cb16e157a36dd1c5b08bdabba09f844a71d7e47c994f", size = 19118, upload-time = "2026-06-01T06:00:20.091Z" }, + { url = "https://files.pythonhosted.org/packages/ef/aa/da280870eca03223fb1c15e5c5482ebd42a523a227e496d9b490e8fdaea5/grpcio_health_checking-1.82.1-py3-none-any.whl", hash = "sha256:622ed6663daf0b8c9dedb4e95a48f6db080a8129922742b5141c63cfab373219", size = 19121, upload-time = "2026-07-08T12:39:25.629Z" }, ] [[package]] name = "grpcio-reflection" -version = "1.81.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/98/39a6972bb9a90750e32daabacaab7b4418e4384f7f2f686ff5af3d69094b/grpcio_reflection-1.81.0.tar.gz", hash = "sha256:5191db7aa6cab1b6981b0879fa44fdcdd43ba644f0301c40b976f813eb4eff06", size = 19192, upload-time = "2026-06-01T06:00:33.419Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/85/a3db5bfc805d6dcad07c592e342199e9624b3a5d16a739360124044ed8d1/grpcio_reflection-1.82.1.tar.gz", hash = "sha256:2ec943ead3e17b43f8e0747a5cb417b3a64357fe3d9b4a7bdc39a4c33ea9800d", size = 19217, upload-time = "2026-07-08T12:39:37.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/61/e472357ff5484f67c802568e70b3182df3d8eb1d0c2de38199d9a9a28bb2/grpcio_reflection-1.81.0-py3-none-any.whl", hash = "sha256:85322a9c1ab62d9823b1262a9d78d653b1710b99b5764cdcef2673cfe352b9c1", size = 22907, upload-time = "2026-06-01T06:00:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fe/f2fead4c021dad6a419e9b0324918b28b7154ad52edd5a4a3fe005fdb471/grpcio_reflection-1.82.1-py3-none-any.whl", hash = "sha256:4df1a3b9c62a3dbdd910a0f277428bc8f5da03a51057a5e61cd2201b09d985c5", size = 22909, upload-time = "2026-07-08T12:39:24.395Z" }, ] [[package]] name = "grpcio-status" -version = "1.81.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/b6/cdc177114997d15c887fb09ccfd16705c8ceb8b4ca2487902b54a7bfd1af/grpcio_status-1.81.0.tar.gz", hash = "sha256:b6fe9788cfdd1f0f63c0528a1e0bfdb41e8ff0583e920d2d8e8888598c01bb69", size = 13900, upload-time = "2026-06-01T06:00:32.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/4d/3037f220cea14be7e77bb52e7dec18bdc90554e218642c8ebde620de37e3/grpcio_status-1.82.1.tar.gz", hash = "sha256:d9de8ac34763cd468130fdd2923294af7c3d28d09426f6c45221d27c25931130", size = 13906, upload-time = "2026-07-08T12:39:41.943Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/b7/5aa346bf1cdecd4ed64b86c10a4d5a089ce3da89145f8328caf0b22b240d/grpcio_status-1.81.0-py3-none-any.whl", hash = "sha256:10eb4c2309db902dc26c1873e80a821bf794be772c10dfd83030f7f59f165fab", size = 14634, upload-time = "2026-06-01T06:00:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/46/5c/2f6c7e24b99dbaf5f8d7e5b1413fc9fc23360cdeb7f290b49a1c87b49560/grpcio_status-1.82.1-py3-none-any.whl", hash = "sha256:71c7f2bea725c0027fa396b77a55d4e9d90591bab90de4c1c03d4df9a56552f0", size = 14636, upload-time = "2026-07-08T12:39:23.113Z" }, ] [[package]] name = "grpcio-testing" -version = "1.81.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/3f/eb2159852185cfed6ad1fbe26696c0dbff7824112af3556caa3430cb09a4/grpcio_testing-1.81.0.tar.gz", hash = "sha256:32370bdffc0cd09abade928fd880f919bf070afe5d012a2de1412427b2dc1921", size = 23127, upload-time = "2026-06-01T06:00:31.851Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/f9/0c334dacc948fd448fce68ea8d43228890192a13583ac50f98c667db6c97/grpcio_testing-1.82.1.tar.gz", hash = "sha256:d9fc662d245fd742292d038990242d0dd0e692f00a02f210bd1234145cf13341", size = 23150, upload-time = "2026-07-08T12:39:40.368Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/69/cc76b0ef3248cb5f8389fabb9d69e23c597664e5f7f40861e3ebb04bd46b/grpcio_testing-1.81.0-py3-none-any.whl", hash = "sha256:f754ea3efd110127920513f0177f31b5c9bcbdc7e4af9ac568d8b3a5684df3c6", size = 33407, upload-time = "2026-06-01T06:00:14.991Z" }, + { url = "https://files.pythonhosted.org/packages/ae/56/0fb749334d54b0df4e9605daf49041d33c78b7d0b1bf0286ed1b28dfb096/grpcio_testing-1.82.1-py3-none-any.whl", hash = "sha256:4542e48050aa5737a95c2ff2f089db0c4eb110a3aee9713907efd69dbec7c4b0", size = 33407, upload-time = "2026-07-08T12:39:22.044Z" }, ] [[package]] name = "grpcio-tools" -version = "1.81.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/b5/72f688670ce56ea59b05ea13430f06cbb728dd354dac508544fc7d4b5c95/grpcio_tools-1.81.0.tar.gz", hash = "sha256:0733d773eca8cb461f4f2a1b79c64c123db9661be41b08184b81497b2b991ccb", size = 6235718, upload-time = "2026-06-01T05:58:34.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/1f/03480308383e55d5189de6fee6d6b4728b2c2fa7129ba450dcf9bcc7b099/grpcio_tools-1.81.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:ffb1e507f9849ee013473f732b1ca5d732457f9b1d0d298efe8a77c33bb65d3a", size = 2586260, upload-time = "2026-06-01T05:56:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/2d/bb/a8846bdaccaa8ec9f32b2a3e9b4fd5f364c142bb63e8159629c0d08cb346/grpcio_tools-1.81.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:fa529a1f7a946cdb6881141a07d8fc8fb074af2fd17323e5f3530c8718101680", size = 5817404, upload-time = "2026-06-01T05:56:43.955Z" }, - { url = "https://files.pythonhosted.org/packages/35/de/31903d2110cb7472aec4d005c2b96bab645fa5bd69d4c63a9b442e3e86ef/grpcio_tools-1.81.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:244619491a12d1f8d4119bb5930272827084a1e7caa8f9d5a0d4ca6595bb1dea", size = 2634107, upload-time = "2026-06-01T05:56:45.844Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e5/aa3bc715bcff192c98893bde79e2ed56cc5fdfdc2d479c9b39a9091db466/grpcio_tools-1.81.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecb833118bfa2c3051e534692f2b43f7c6db14e1eac9b1e7f6e66b6b6dc5074", size = 2957944, upload-time = "2026-06-01T05:56:47.915Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8b/48cbc3db78a74323038bd36db26c8ec24d4c69e1361b3f84660d85ad3c9d/grpcio_tools-1.81.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0034997767960644f0f4a2aecf35df5fc426d43fa5639dd1b8317da835bab2a4", size = 2697758, upload-time = "2026-06-01T05:56:49.749Z" }, - { url = "https://files.pythonhosted.org/packages/67/53/2e760230944bf028052d8fd36141e357b9003a38a2928f0714611cf7049a/grpcio_tools-1.81.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5a7c89432a3b200be306e76307b866716663b4c71d7816bdf382af81e00abd51", size = 3147589, upload-time = "2026-06-01T05:56:51.804Z" }, - { url = "https://files.pythonhosted.org/packages/cd/52/06df77cfcc031c4d45eb3e9c5d075ee3ecdfe7d61f73a9a87f1f954b4c1f/grpcio_tools-1.81.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:52687858c044a6a4dd3fc4049b012b131254a3061360d6de2f97c3e38a7aedd9", size = 3708798, upload-time = "2026-06-01T05:56:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/3c/68/1211c5c052e5c1ef682ed2d1fb70b2ace0945f8492b66b3313b28739e52c/grpcio_tools-1.81.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c5f44305786c1295320fed477f5673be4ca5f6ed1b31f8696f0791d4b12906d", size = 3366971, upload-time = "2026-06-01T05:56:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/31f4d9aa43e461bf0641531ee01242fa46407ec8b3153b484725a910d931/grpcio_tools-1.81.0-cp310-cp310-win32.whl", hash = "sha256:bf19fdc8b6258fbf36ea65f5672206483ef4639a16d33e3367d77e4523b4089b", size = 1008713, upload-time = "2026-06-01T05:56:58.154Z" }, - { url = "https://files.pythonhosted.org/packages/9c/83/322502cc56f8eca3e0dae8cd1c2740a5611d6ea03afc3193c7afb8ecda37/grpcio_tools-1.81.0-cp310-cp310-win_amd64.whl", hash = "sha256:f02d796474e58bba879965d228874c2c34164b6a5d96c0faf6bf896a6c3d8a0b", size = 1174751, upload-time = "2026-06-01T05:57:00.91Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/f48f4798d20c3db6d17bd35c8132c64ce7136584411c5d260b8d4276535f/grpcio_tools-1.81.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:677207d88b659048f63697c237bf650b123a7a1de36158db57176f84c5bca84a", size = 2586250, upload-time = "2026-06-01T05:57:03.305Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/cde1c7e7abdc46c3c56de9785e89377f614567ffce506388664f143d1dbc/grpcio_tools-1.81.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:f37ce0440e5dc563662da89d7d1edd20654e6fb615ada5c8027f15f881bd40d0", size = 5818006, upload-time = "2026-06-01T05:57:06.18Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/3f19ed6d2de1dafb3b542f0132037d84093c4b30fe8333349fd575e1f586/grpcio_tools-1.81.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:404ca893d54f26fddb868bbd4a54b203fbf914264d26c52e2f2aff35851a9f72", size = 2634061, upload-time = "2026-06-01T05:57:08.265Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c5/7d1c6c577e2909f6a94b1e623b01fa993c975aff58283746f48dbcc5e9d7/grpcio_tools-1.81.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:fbe12f98aeddfeaa74d3dbe41dc451f008edf3ab1b82eb62f7a61011003d4833", size = 2958026, upload-time = "2026-06-01T05:57:10.679Z" }, - { url = "https://files.pythonhosted.org/packages/91/c9/73873504d23536c5b31efe0e7f3c2911514433b627433a830d13afaa8097/grpcio_tools-1.81.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:012d580d98db189e4bd231a6529b162bc27a5f52c4854e2d21a4016edc47c760", size = 2698031, upload-time = "2026-06-01T05:57:12.657Z" }, - { url = "https://files.pythonhosted.org/packages/a2/3d/89d9dca7db8d9565a4ab1f47df0579355e53bf066a40e21143b30debc205/grpcio_tools-1.81.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d744ea354c9ca33ca17522436825e2842df9b731bcc924a73f4a7d205ed53006", size = 3147544, upload-time = "2026-06-01T05:57:14.829Z" }, - { url = "https://files.pythonhosted.org/packages/2d/55/b1da79d9b19a9fbc9c5fceac69d9dbe67cc184735f25512bb9bd070a9754/grpcio_tools-1.81.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:40b7a17629e5c944c8ba98a438f051b9affaf30794e6e096578ba0030725f90c", size = 3708524, upload-time = "2026-06-01T05:57:17.077Z" }, - { url = "https://files.pythonhosted.org/packages/a9/08/e019d647311f90ff6ce4eac42c6f2c39282e86ca29ede8cc00039c23011d/grpcio_tools-1.81.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23831f5c66038c5793cb5d2651120bc24a33705e1ce2887a88f54272b73b240f", size = 3367019, upload-time = "2026-06-01T05:57:19.575Z" }, - { url = "https://files.pythonhosted.org/packages/74/70/6afa8d0dcf7c14727271a6913803f821c0365853b4419a71844cc5cadc76/grpcio_tools-1.81.0-cp311-cp311-win32.whl", hash = "sha256:d56060281599d87e66a0dc6840b68730d81c215dbb1b5c50882f819bc9b6aba5", size = 1008980, upload-time = "2026-06-01T05:57:21.32Z" }, - { url = "https://files.pythonhosted.org/packages/f8/22/d6317bd68ba49b1eb89ba6f1066808d751a57ddfd4b1b964d96bf6dbfa84/grpcio_tools-1.81.0-cp311-cp311-win_amd64.whl", hash = "sha256:42e1eaa98199bd4f900f8af091e27aef804dd53b59c92adafcc9faabc0a92240", size = 1174844, upload-time = "2026-06-01T05:57:23.213Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e3/3c4f9da489413ef3f3dc9f7bc49a270270ec99fb3a00fd4302a2f59a7be2/grpcio_tools-1.81.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:374fe0435447f283e3c69f719ef1bc66f2e187a239ce25444b2de45cb3a6a744", size = 2585927, upload-time = "2026-06-01T05:57:25.397Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b2/de7aba18f87d722c215ae168add975b9e7729cfaf7a1292be43f87685fa1/grpcio_tools-1.81.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:dce33d09851bc15dead814bd9d21023bffdf0f838ecf65995a2456e5692831fd", size = 5815566, upload-time = "2026-06-01T05:57:27.714Z" }, - { url = "https://files.pythonhosted.org/packages/eb/13/8f71b4830f129d896560c66964a3a8f4e33fbd59854396015e7449b75d3a/grpcio_tools-1.81.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58e65dd13f7ffc25f5a9cd9890fddfd39b3c51e5c3c1acd987813b1dc1173704", size = 2635519, upload-time = "2026-06-01T05:57:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/a2/e0/3ad58f1791c346a1fefc69ef3fcd19d63e3778736d4746f12b39f900b78b/grpcio_tools-1.81.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:760775f79bbefa321cd327fe1019a9d6ad0d93acb1ede7b7905c712679542fd7", size = 2958250, upload-time = "2026-06-01T05:57:31.836Z" }, - { url = "https://files.pythonhosted.org/packages/79/8a/3212db57815df0fa2a02e857e402c1abea15ec6b5fb63ebf306d90f2fb07/grpcio_tools-1.81.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7af4faf34376c57f4f3c42aa05f065d3b10e774b8a8d8b27d659d5cc351e5c75", size = 2698437, upload-time = "2026-06-01T05:57:33.827Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d5/4245bbb4c14b54ac539b5b59f5298750d75211e144e1e8b35e1af5144d6d/grpcio_tools-1.81.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9945945edc14022abab07caec0ebc16bf51219e586b4f008d09334cc479655c", size = 3152159, upload-time = "2026-06-01T05:57:36.065Z" }, - { url = "https://files.pythonhosted.org/packages/59/b1/59500d9fe41209e0887c66d80879ba80d0cc8e1327e24cc783eb879fe7a7/grpcio_tools-1.81.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:61d9ad6b5c0f3857663701bdc2cbb3da7f7d835ce9a8d597ffca443124a96894", size = 3710468, upload-time = "2026-06-01T05:57:38.367Z" }, - { url = "https://files.pythonhosted.org/packages/0c/14/86fc8b64db62851bf5cb1c945b22da7ab0dec6e0b7002ec374247482d404/grpcio_tools-1.81.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ffa74b84d201bea407f22e00ac0367f12df48a0073b0ffd9f3be9d8126a55245", size = 3370795, upload-time = "2026-06-01T05:57:40.724Z" }, - { url = "https://files.pythonhosted.org/packages/94/95/4fd57d9f948adadbe5b3e8e3b0d3c121ae5fe8276721097ab03361ce7adf/grpcio_tools-1.81.0-cp312-cp312-win32.whl", hash = "sha256:f1f407697873acbf1d961c6fb9223114a3e679938469d4186623b8b872dbdae0", size = 1008449, upload-time = "2026-06-01T05:57:42.491Z" }, - { url = "https://files.pythonhosted.org/packages/33/94/7567ddd3a13e24bbc5e146c6ac735004ab7303048115ccb032d61b5c2305/grpcio_tools-1.81.0-cp312-cp312-win_amd64.whl", hash = "sha256:283bb3465331a4034b14dce35425c47b0cfbd287b09a6e9d15c9f26fbb17e799", size = 1174889, upload-time = "2026-06-01T05:57:44.405Z" }, - { url = "https://files.pythonhosted.org/packages/f2/05/f0606a1b2e830d5fddfcd77c5d8e928f26dc221ced386fccf31a6efda57e/grpcio_tools-1.81.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:33c579bf24040dbdce751e05b5bcedc13dafffa8f2dfa07193bc05960dc95b49", size = 2586070, upload-time = "2026-06-01T05:57:46.677Z" }, - { url = "https://files.pythonhosted.org/packages/c4/4a/3b6817547d65d9f7a106ea6a2352125d08b44ce1d120b64ca0c565d896e6/grpcio_tools-1.81.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2a369a9e27fcb6279387744778d67a64551de82db0e9cf7e1e9451c22f719e07", size = 5813211, upload-time = "2026-06-01T05:57:49.159Z" }, - { url = "https://files.pythonhosted.org/packages/bf/fc/078422558ebb337233379cdb0e4cc0d3d3218933d105003bae2790ff976f/grpcio_tools-1.81.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2b26111795d0e7a72fa483d377a75b57203edaac8bbbac1e9b8f174e7b01ff3", size = 2634663, upload-time = "2026-06-01T05:57:51.41Z" }, - { url = "https://files.pythonhosted.org/packages/53/2b/043f2d62d6f28a0962f29f180ae24770020a06985b14a2d8f0d489531d71/grpcio_tools-1.81.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a741007631248dd903f880b575a63c0662433ed4b966262ab0f437ea860c9b7e", size = 2957926, upload-time = "2026-06-01T05:57:54.103Z" }, - { url = "https://files.pythonhosted.org/packages/76/d3/be8c1f7c5ca6adccba66ef787b4bba304a3247c1319a33ed330719931ba3/grpcio_tools-1.81.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bac1c6ddc5eb3762257e773829b4e00ea7d0592f02498f27e5bdb75cd3182d69", size = 2697761, upload-time = "2026-06-01T05:57:56.494Z" }, - { url = "https://files.pythonhosted.org/packages/19/9d/c1650c72059f7d20d94597430ecbe0139d92c7e409cf007d0b5d765e40ec/grpcio_tools-1.81.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5d241c694a226bada06dae9accc47c5c25e586f49464823498e84cff63eedea", size = 3151460, upload-time = "2026-06-01T05:57:58.756Z" }, - { url = "https://files.pythonhosted.org/packages/6b/82/2aad863738dc4f749df93d97f81a8aebdd0a7c5daee7157c259ecea7dbbc/grpcio_tools-1.81.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:475286558410e4d0394fc8129559080c22835ece3c23cac194b46c5ad7d0fad1", size = 3710466, upload-time = "2026-06-01T05:58:01.327Z" }, - { url = "https://files.pythonhosted.org/packages/47/7d/b79a5b132bd5db76ce48e686579ded0311bfa7ea19b8cb9ca88aea3e8d64/grpcio_tools-1.81.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98b57a592a75553f4dd38db3993b037953245991b8df9f06927a8978962dcd82", size = 3370487, upload-time = "2026-06-01T05:58:03.769Z" }, - { url = "https://files.pythonhosted.org/packages/96/6f/a6a74a51b71aca801f4dcce6bd8bb7d3fd8d556ff191f0bd0de631b97dd1/grpcio_tools-1.81.0-cp313-cp313-win32.whl", hash = "sha256:78c2514bf172b20631685840fea0c5d1bec5518b649d0a498f6dd8f91dfce56a", size = 1008234, upload-time = "2026-06-01T05:58:05.668Z" }, - { url = "https://files.pythonhosted.org/packages/5a/91/02a4c529dd0a77c2d768b415ac334f6a9ea9d61c6f4c38e54d1a4394530c/grpcio_tools-1.81.0-cp313-cp313-win_amd64.whl", hash = "sha256:a87ea8056beea56b24353d27b7f0ab814daabb372aa517d2e179470e66fd8f6b", size = 1174523, upload-time = "2026-06-01T05:58:07.711Z" }, - { url = "https://files.pythonhosted.org/packages/13/1f/7885e23074d813ab71ba3ea689ecf5cb3bb3c76c51cc01bd393451f10257/grpcio_tools-1.81.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:bf2ffd98c754d54a5affddfe3a18d4822b972c5c37ca6a33a456c94e6f3dd82b", size = 2585943, upload-time = "2026-06-01T05:58:10.077Z" }, - { url = "https://files.pythonhosted.org/packages/40/f4/a88116147d377a88fccef9b43667235835d504d60ebe0e0dcc81bc9c4b20/grpcio_tools-1.81.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:4a8d9fdf42537cd1924f7e95013771aa73c89501ccf112b7517c22ca825be9f5", size = 5813367, upload-time = "2026-06-01T05:58:12.545Z" }, - { url = "https://files.pythonhosted.org/packages/2a/1e/01f310f0427dcddaf0097e4101f041d437ba8199a7ed62621788f2601042/grpcio_tools-1.81.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:564624654f5a0377adc69f226f93ec5ff52715030c2f846af6c54ccc4ca2d225", size = 2634992, upload-time = "2026-06-01T05:58:14.92Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ee/10cec754cb89cf45039ef4a8ff500ab5c567278fc4f6333347dba20c99fb/grpcio_tools-1.81.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3f1ac691debbdbf00e7635ecacf28647f48f67fb4b14120a4541c8bb0f8333ae", size = 2957912, upload-time = "2026-06-01T05:58:17.596Z" }, - { url = "https://files.pythonhosted.org/packages/d2/71/cc273059fa3620d424df91a8faabd5875d4c20a0300875e721d15decd74b/grpcio_tools-1.81.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f35da7b3b9537ecce9a5cfd967b1316a815378d5a3729e9ea556b22a14f6e315", size = 2697709, upload-time = "2026-06-01T05:58:19.653Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d5/c72f9e7d18425586bbc5b4fba78570fab23cb9810fb34a8841f7baaef22b/grpcio_tools-1.81.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1e21049aeedd9d62e5e58146e5d00f3b75674d8d8d3cc69709ab950082be8421", size = 3151885, upload-time = "2026-06-01T05:58:22.366Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f9/7a3d7b3bce72fe22611a79d3790440c16af9d524a8bd1d38a89a44c65570/grpcio_tools-1.81.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:019f05a17b5495d561603f75a74a4a76ad22456a95dc7623c7be4e4b44391b88", size = 3710403, upload-time = "2026-06-01T05:58:25.014Z" }, - { url = "https://files.pythonhosted.org/packages/58/2d/b41fe47b83eb197a48fdcbf48d04f5923c5fd62d4b1a7f2820720562d7ae/grpcio_tools-1.81.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3401d6d4668c064c9ed344825fe97ff076cd8ba719a24e3d3c7169b604cbf00f", size = 3370524, upload-time = "2026-06-01T05:58:27.096Z" }, - { url = "https://files.pythonhosted.org/packages/94/d3/a4565146ab83232ddf86a3de497937662dc06649739f978763379c256311/grpcio_tools-1.81.0-cp314-cp314-win32.whl", hash = "sha256:5783b6758244f6eaceb41a0e651828824b0d0c92724ddf4b68879ced9bfd9b50", size = 1030574, upload-time = "2026-06-01T05:58:28.972Z" }, - { url = "https://files.pythonhosted.org/packages/35/72/f2102f3737b94e14b8ce56394918c0a4303e80d5d8b0627fc4ee85927f79/grpcio_tools-1.81.0-cp314-cp314-win_amd64.whl", hash = "sha256:69f8355b723db7b5e26a3bff76f9deb3a407d22fe289bca486ccf95d6133cad0", size = 1207499, upload-time = "2026-06-01T05:58:31.606Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/af008a0df6f9ec85ae136f763aed207e68097c952a17443d2c2af9d60a91/grpcio_tools-1.82.1.tar.gz", hash = "sha256:2bd3176ccdbf7cd1f463eb75b7b83544c7d6429f5ca8a0f7f784b76097dac891", size = 6399590, upload-time = "2026-07-08T12:38:15.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/23/71744dba2fca8c03456e3ae205930363e26bac142f08a5967ec8b2fd7091/grpcio_tools-1.82.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:552bd37a5cd9cc19c453daacce572202bf33374b238d6d923458450b6cf0be44", size = 2652630, upload-time = "2026-07-08T12:36:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/45/82/c59decdc4ab5cfa393a6fab5cd132022523ebc66690e5f87d0941c3e7353/grpcio_tools-1.82.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:a97ed72b7222d47c265dfe7fa12846b9f9fbb1ccdf992aaf210b4f08083c1a47", size = 5967247, upload-time = "2026-07-08T12:36:27.096Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e2/0bc295e90acc987aa3eb5ddb696555f3e0ef99c22a5c2108061373117831/grpcio_tools-1.82.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fa2fb5b0dc1db1fc12c3820919b05f6683d2d70c70cd522ec9328744171d3e6", size = 2704702, upload-time = "2026-07-08T12:36:29.111Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/008a1ac9780ba3966b94c795ec37906c9a0389ccdd9f487e0eb3f5574fb6/grpcio_tools-1.82.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:43eebbf0ed16b390f94d60ac7180214c43121de53daa4265545a7bc4b62472fe", size = 3032301, upload-time = "2026-07-08T12:36:31.132Z" }, + { url = "https://files.pythonhosted.org/packages/51/88/5e4d025258d44f19ca49e134f024f90eb9e719c5121987b02a12b2d31471/grpcio_tools-1.82.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba890febb60da0c7b4b5a08b892eaa6ceb8467db3fade7dccac290eaa82ddbbf", size = 2773913, upload-time = "2026-07-08T12:36:32.954Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/36d77d65666d8aeac58d32f4f8d5e852d42a50a92e76b1bd3445aa72a757/grpcio_tools-1.82.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bfef58b660ccba75d1954661f2ac6aac1422b9170bc296c33d4b6c0e89e22dd2", size = 3226604, upload-time = "2026-07-08T12:36:35.041Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/33b425b33e1a045b9768d1dece8f1149a5ebae8a27c9f1f6e7e69f733970/grpcio_tools-1.82.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b359a74a488ea6dd24a12c0fa783a7d2be60a0196aad9914796d8b97763193cf", size = 3798907, upload-time = "2026-07-08T12:36:37.057Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a2/17987b71ed84077f35b977903cfec4610904f46c6cc37c263eb1986f1ad7/grpcio_tools-1.82.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5dd3b2618afb706bf03703c7c89fc10e8e65f31f2303b9fe02a9f6147404bbb8", size = 3457747, upload-time = "2026-07-08T12:36:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/0b/db3c7fe70277835286987978801c00596355b64c5c5fba93d58df419b637/grpcio_tools-1.82.1-cp310-cp310-win32.whl", hash = "sha256:39c5e43ff25ae80d11c9e4374ea685df42e1288b62383ee6cddac360c3f400e7", size = 1022568, upload-time = "2026-07-08T12:36:40.599Z" }, + { url = "https://files.pythonhosted.org/packages/40/9c/ec5016e202ffdbd8f9d0024d27ef3e6beff0bb48d371d60b993740c47517/grpcio_tools-1.82.1-cp310-cp310-win_amd64.whl", hash = "sha256:a71e8f181bd549f99783257a0d736e6d53851f4f931e72885e08d4fd8b01245f", size = 1191974, upload-time = "2026-07-08T12:36:42.231Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/d55020c1c479431ef217be61396793a055f7d451d8b1def85aa61a909334/grpcio_tools-1.82.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:ebbac20ac4754d19d4a3b6d79f5d6293a4f3be87d4028a39910b5a9a9fc30351", size = 2652837, upload-time = "2026-07-08T12:36:44.231Z" }, + { url = "https://files.pythonhosted.org/packages/36/dc/f083108afc41ef52b1e3ec4de64ab99e9e4e57736d1c93fa932b8fc05549/grpcio_tools-1.82.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c3e3723d5c5735b24fd0d2a2c97f58981cf25d7a44bb11d15d787346946a1749", size = 5967878, upload-time = "2026-07-08T12:36:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c8/7edf03179e78f339a177017c4fcb4e9086a473be1a433126a8f59592bb24/grpcio_tools-1.82.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:047651b7552d2e60254a8fb44a72831764d812d1ceecc31dbf9194a388f89d3e", size = 2704942, upload-time = "2026-07-08T12:36:48.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/96879635869cf6e36bcc276ae373be3e1292cd806205eb8c2c2f41bb2c29/grpcio_tools-1.82.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:381886c71a955f074d16e5fd92ac40550c3b41dba215a6b16f9657b5aab5c4b1", size = 3032318, upload-time = "2026-07-08T12:36:51.072Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ea/48be2380884ace92f19a04638d6c9c559f53e3c3e393893499c6cc257016/grpcio_tools-1.82.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa2708db1c7989cc3e1274885d1faf4486f251c9dfe67d22cda8a3e72da0a80", size = 2774108, upload-time = "2026-07-08T12:36:53.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bc/2cba701aff07ba650b97484e0b42dd7e2a13fa52b1ad1cab6c0273da279f/grpcio_tools-1.82.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b39f1164ffc992ca76e78a3a54fe439154d0f0d9cb7ca1545c56fa39e8b912f8", size = 3226698, upload-time = "2026-07-08T12:36:55.254Z" }, + { url = "https://files.pythonhosted.org/packages/00/e8/09cf2f4a4259a456df9cbc942dd3d94b243acb846f0a9e4f6dffeff13bfe/grpcio_tools-1.82.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:45a17d4d5cc8d43717983f9536ece5349f0fb529e14163313505c215f8e456b6", size = 3798987, upload-time = "2026-07-08T12:36:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/78a2dbad9084795f04e140417585ed780f471f6563e02e565d62d88b4bd4/grpcio_tools-1.82.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9987b6b1a3b0e3f23ef792d478710992bfb9fdd86656ac0907f659ec85522c21", size = 3457771, upload-time = "2026-07-08T12:36:59.408Z" }, + { url = "https://files.pythonhosted.org/packages/c6/62/b0bd212e732be2576dc458385eec6969347d97bd3c76a366007354693ad2/grpcio_tools-1.82.1-cp311-cp311-win32.whl", hash = "sha256:2dc55c0fab9967d3277a09b87fd8911bffc5b672c8a15066768a1667983f0ac1", size = 1022868, upload-time = "2026-07-08T12:37:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/8d/24/c21af6e02a8f4fc9a6b7d0a23d765f113b8fbf642c704d89a8d80f8939ac/grpcio_tools-1.82.1-cp311-cp311-win_amd64.whl", hash = "sha256:4baf943b57ff0f8410a4633cc0568ac2611a64ab1b056a1a5d30268a07d8f80d", size = 1192442, upload-time = "2026-07-08T12:37:03.207Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b8/70021ba4ea39ed54f175ae79ac9c71b3104ba965418b416e85e18b661d3a/grpcio_tools-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:1b1ae735ad45f8a01715b0106020803330a68b20b17dcdf51e8b7266af44a9ac", size = 2653283, upload-time = "2026-07-08T12:37:05.6Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c5/add9b6f3780aaee6c1463e1295494219fbe849119f7a7eb4968bc677a50d/grpcio_tools-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:e6ce264293507e0a0f2facba230646fd185c18cee14a41bab26bca54b29d6a39", size = 5965914, upload-time = "2026-07-08T12:37:07.985Z" }, + { url = "https://files.pythonhosted.org/packages/1c/76/8849d262571edc9343ff5c2186c8afc61e960ad2b67d9ddc154e02953acf/grpcio_tools-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75856fb0ba6a574e62b02473b10cb2479356b5db07c39ef8209395105608dc5e", size = 2705355, upload-time = "2026-07-08T12:37:10.279Z" }, + { url = "https://files.pythonhosted.org/packages/69/1f/fc34c4af2464584b31110a8b81e48debb28b0204bbdc6bd5fd625d710c23/grpcio_tools-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1d7d1f0d0c1fea8bbd6002f7a4ad1eb034b9114f4cf64d6a5d6aaa587725ae02", size = 3033411, upload-time = "2026-07-08T12:37:12.414Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/e42981d84b2e7be1563c6d4fc012330ab6cb42c1f053b8ed81e3e9e5254c/grpcio_tools-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b926e4ba0afb0a69954ef5ffb039b593676edb16d8c750476e65b1de89b535a6", size = 2774501, upload-time = "2026-07-08T12:37:14.401Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f9/1e99a12feb9599077b591ae9814daf85a97ff28fcd57571f08d42c5efff9/grpcio_tools-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:91bd88cf4bd6129620a0a27d051cb1c7346e3da498251c18d9df3061c852de3b", size = 3230020, upload-time = "2026-07-08T12:37:16.528Z" }, + { url = "https://files.pythonhosted.org/packages/b7/88/b82a5eebdf98208256326dec9ef752a3b52e22c8e8ed722cb96e81c0d520/grpcio_tools-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0972d57773ab2861d39df5e6d3d9e1a1008d53e74f8a5f84dd2932dd907e0ac1", size = 3803155, upload-time = "2026-07-08T12:37:18.706Z" }, + { url = "https://files.pythonhosted.org/packages/78/a5/ce7c35e47ed87a46a66c76c104c11204d8492600fd8411260cac5d9f6253/grpcio_tools-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfd5e337fa40885b82c782968a0d67325a5b769c4e2542cc6fa48133bf6dc97f", size = 3461816, upload-time = "2026-07-08T12:37:20.719Z" }, + { url = "https://files.pythonhosted.org/packages/e5/56/b7fae69b9a9b68df4bdaaaa7ec2e836ba29f811eb2c295bd0014933fd719/grpcio_tools-1.82.1-cp312-cp312-win32.whl", hash = "sha256:518f58639014bf1bcecd9055dc63b6f33d70fd8e7621a15ce7c7d628545b4199", size = 1022473, upload-time = "2026-07-08T12:37:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/b9/81/40c863fa3e84f818dae2f6a58c02b7ef81807f65714f5cefdea3596edf2e/grpcio_tools-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:f28239d935da567af046957b245eab4b1c5f694369a00f0ef2a0a90a63a8ea66", size = 1192176, upload-time = "2026-07-08T12:37:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/53/08/934dd729d3046e4ffd40ff897c26b7391b7b73c21b171c4c52edadc2f933/grpcio_tools-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:fe2e289a95ff818da6e0548ba3d9e24433895b535e1e837ed46900624b1e91c7", size = 2652843, upload-time = "2026-07-08T12:37:26.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/2b/1f4a160a486ac9ed3c6b35a04ab9c48ec9b31141c1c0ff7c27373c0a67ea/grpcio_tools-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:216a476aa5444e66007e53ba0a4c7128f9ad01867ec1ffa788b7c72984a546c3", size = 5963549, upload-time = "2026-07-08T12:37:29.165Z" }, + { url = "https://files.pythonhosted.org/packages/74/2b/8a2675dcb2be98b7cacd09367637063c295aa6008c83c962def12cf47f44/grpcio_tools-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:92d59cc6232859c760646bb353efd83677e931c171d58eaeaa9451ce41706b58", size = 2705081, upload-time = "2026-07-08T12:37:31.235Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1c/ac011ab4110a2bb37e5af9d6d911183d3cec3fdc178f20477c0582b94d04/grpcio_tools-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:67e2896338b4299c1363d91856f55349fb9a247d7ec420b7ca021ca1362fb7c6", size = 3033064, upload-time = "2026-07-08T12:37:33.652Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7c/b36f97d0457af255ef5b6ef924b7aa6328b706218e312503b4fbe7056e4b/grpcio_tools-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:452b4880b7f5ca2bbb6fd26e76ac0e10579afa51e589b45ad7562b034f954642", size = 2773651, upload-time = "2026-07-08T12:37:35.789Z" }, + { url = "https://files.pythonhosted.org/packages/8a/23/d084183effc6e4086fc78d318e510a19bbc3d21d85a9b4eb3236f131618e/grpcio_tools-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57b35422bec9f7b0eda98cfe057b272102d7662884116d335e8fa94fe446bead", size = 3229769, upload-time = "2026-07-08T12:37:37.99Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/f4084327ff4d743e57f58bbc5eedda04885ea4c7749d9ea07a0284c4e338/grpcio_tools-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8aa752a4ac0620fd2a427b5bccb772c4c6c9bb497834939481f59579835f0a68", size = 3802527, upload-time = "2026-07-08T12:37:40.665Z" }, + { url = "https://files.pythonhosted.org/packages/65/44/4106351449cfe140d6af39668743eb059c525b1b5dfb37cd4376767bd2a7/grpcio_tools-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:89f9cac1a313c4e72cf83be7e7413f0a34b6c2f0b4e6d8a56288b0bbf4f213ea", size = 3461032, upload-time = "2026-07-08T12:37:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/34/87/60b3be7084be622edff5781d6b82fd24d4bc00f53fe27350824107b3d637/grpcio_tools-1.82.1-cp313-cp313-win32.whl", hash = "sha256:8aa2079a166ef51cecbdfa677ddbfca9d71eb0fcbb3e61dd74c61eb52723d1e9", size = 1022125, upload-time = "2026-07-08T12:37:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6c/d2460754ab3031d82f6dfd5aea0fdf95ba1004fb56a9a302115da8c4b7ea/grpcio_tools-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:4c00edd39d65b4eafc499b934fb7198788750663d7516718a022d0dd80f4d85c", size = 1191848, upload-time = "2026-07-08T12:37:47.795Z" }, + { url = "https://files.pythonhosted.org/packages/b0/8c/5c2130941fd30d59326fab4c2fe8f8e1c954ebf864c9d2a14d767cc07333/grpcio_tools-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:e6499e7009c38e23f4c9ffc64efa46d3a1ac0c3b01b256e77b9816f7078eb4db", size = 2652840, upload-time = "2026-07-08T12:37:50.264Z" }, + { url = "https://files.pythonhosted.org/packages/33/37/9447cada0b29e3423c38905fcc552ccdaddecac96e6a4ab2ba330e7508c9/grpcio_tools-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:35d79c00a4da740abbbf7fbf1f34151fdca9917885a4a4235d426438a7973aed", size = 5963503, upload-time = "2026-07-08T12:37:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/ce/55/4d4ec2e1064abd14264c3beefba9b46f4c13efad85ad0f1f87eb40ddb2a6/grpcio_tools-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddd9ddbf43d1a4c472874bed2c491a89be9bf36812a56ed8892f609aebd0a844", size = 2705216, upload-time = "2026-07-08T12:37:55.705Z" }, + { url = "https://files.pythonhosted.org/packages/a8/36/ebd5334dccfe8411487c2feac639bdc275ec263bcd3ec5b620d715ce90ea/grpcio_tools-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4fa16c11e1c9ad3f35f4545445e217a725359ed235db8ff42c3b40b131ec48b2", size = 3033046, upload-time = "2026-07-08T12:37:57.982Z" }, + { url = "https://files.pythonhosted.org/packages/48/06/69255a28fcb9264db954e8ca6a0eefd692f6efc2a9fef1f0920a42267070/grpcio_tools-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01c4c5333908b2050a14461f5d71170c948628987322b4526e840159b94ffabe", size = 2773832, upload-time = "2026-07-08T12:38:00.428Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/d7895783de780071f90c7ffb36e534fa1468ef2c5a039ee8c2d89478b1b0/grpcio_tools-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:75eff2a53ec1f00968d7e018de35b997134a4381c5b769713664b2625aac4035", size = 3229939, upload-time = "2026-07-08T12:38:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/86/3f/2a74d4c6396e62332c1d244077775b540e1ad15376cc831f66589f2e0fc3/grpcio_tools-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bd0352f3b0341afb911c0b9b177b95811e71476b64004b0d38131a4543d42592", size = 3802594, upload-time = "2026-07-08T12:38:05.51Z" }, + { url = "https://files.pythonhosted.org/packages/3b/69/b559cbea6202bca95cec033c4181413e4417759c90480a10cbc7250d4c63/grpcio_tools-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0a838ae62bfd71ea8cdcd28e3a017206572eb185846e18114c82f8e2c9fb98b2", size = 3461304, upload-time = "2026-07-08T12:38:08.059Z" }, + { url = "https://files.pythonhosted.org/packages/33/32/9e4bdb2e6c62b66e70e5e8d4ec7e542caed57976d7a0b2ef65763901d0ca/grpcio_tools-1.82.1-cp314-cp314-win32.whl", hash = "sha256:335393c9f8d3c0fa6c1b3d168002beabc0cd2d6974d409d216b9d9ebe5b33a5a", size = 1045038, upload-time = "2026-07-08T12:38:10.193Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5b/bea2551e5d79f7486bad32163ea6d6655bd1e8d663cb928f100b901f0dd6/grpcio_tools-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:15c067844adca93ed4661bdfd9b176618ef6b7fd83fb381f2746bd8a1e9f6d98", size = 1224194, upload-time = "2026-07-08T12:38:12.439Z" }, ] [[package]] @@ -1455,15 +1445,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.7.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" }, ] [[package]] @@ -1488,7 +1478,7 @@ http2 = [ [[package]] name = "httpx2" -version = "2.7.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1497,9 +1487,9 @@ dependencies = [ { name = "truststore" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" }, ] [[package]] @@ -1513,71 +1503,15 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.158.0" +version = "6.155.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/ed/4c6cf6eaeeba141f31b56f5c046c5466470eda5e64beb24fee10db16312a/hypothesis-6.158.0.tar.gz", hash = "sha256:28de590146b445edb2a4976b7e9a257d24401eaa4fee699a90e98c1321c2184a", size = 482112, upload-time = "2026-07-21T08:32:58.063Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/94/23d6a665c01dcc4e18ccae7a74f4e5506f79d0429fd87421a2003c62a702/hypothesis-6.158.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6463dcef7c689599702a8a60576401ccfa636d8d548fcb7e0cd17852cc135208", size = 763003, upload-time = "2026-07-21T08:31:20.267Z" }, - { url = "https://files.pythonhosted.org/packages/18/ea/860fe643364921dcb3c1cad83f081fef9d591faebb2fc9666d7a54f1b92f/hypothesis-6.158.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d4d7a24ef1cce3e8283d7f1e48c4d4c9eadf9f0a7180cafe1f049daedf883f69", size = 758645, upload-time = "2026-07-21T08:32:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/25/1f/037d911ab724834160306ca4231f32e524f586d3737be88bb22469e2101e/hypothesis-6.158.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04e1479783ea6d2ae66b87922f611a90a21b7d44c056c71b2132433f5cd26cd2", size = 1087830, upload-time = "2026-07-21T08:32:43.698Z" }, - { url = "https://files.pythonhosted.org/packages/72/b5/2211931139c9592e8bbd00ec9398cf6f5666e6c555672ea1a3d31506b4d9/hypothesis-6.158.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e9dbb5fff5bcc9a846d915aeb7e21bf04ee0057430e3221d8be81dd3eac9f45", size = 1137366, upload-time = "2026-07-21T08:31:24.383Z" }, - { url = "https://files.pythonhosted.org/packages/c5/11/3ac6e87d233b34f8dbb0bd1457aecad62320fea34ca04f7ce06bfc647f7f/hypothesis-6.158.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0764a33f217213db9f291a3079fbcc096043f584752806d7b82641e8f05d4010", size = 1129454, upload-time = "2026-07-21T08:31:51.231Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/842b35e034f9bf1c4d47d98a6ea41679afe5bf120bfb0cc07049ff947e73/hypothesis-6.158.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e271cc58a725b5c193ef34c8eec732a271ccb4fc8e26c8733a47b5948b7b411c", size = 1261716, upload-time = "2026-07-21T08:32:08.222Z" }, - { url = "https://files.pythonhosted.org/packages/f7/4d/2038ce2ead1bc61fc7de08288e4a55f127fa8c18ff66d40fe0f7e6e1137f/hypothesis-6.158.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d033b98e3c07e94a92bb71bd786d431c67e3840928a8c5f9dd845710bdf5728d", size = 1304332, upload-time = "2026-07-21T08:32:18.746Z" }, - { url = "https://files.pythonhosted.org/packages/24/ca/dae12b31d4b324e04a4ada4cbb0831b35857b5a76d946ab0b414f1e605ec/hypothesis-6.158.0-cp310-abi3-win32.whl", hash = "sha256:818bc65c96ebc7c919cb423534a1e892ebac07eda4b7004b7d61cbd2f6b20db4", size = 648871, upload-time = "2026-07-21T08:31:17.96Z" }, - { url = "https://files.pythonhosted.org/packages/38/c5/948d29157cc17711d763424e2f085d70d4b5f9314cb12a1c10310003050b/hypothesis-6.158.0-cp310-abi3-win_amd64.whl", hash = "sha256:737108d7f549f39d0df82e3cdbc27f23183babf38ea8187cf944ea375246d2d2", size = 655041, upload-time = "2026-07-21T08:31:40.716Z" }, - { url = "https://files.pythonhosted.org/packages/50/b4/a519d9569df5942fd541a74d6e4f5f4efed7e13df8f9c9c72d4324ced411/hypothesis-6.158.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f782a683e8cefb847976d8b2cfff0c3ef82df737a98ea37a88641d45568e98e4", size = 763699, upload-time = "2026-07-21T08:32:15.252Z" }, - { url = "https://files.pythonhosted.org/packages/57/01/1b74084eee0dae5ee54d19e7da88a6db2e70d0cd22bf04b45efee3c6e521/hypothesis-6.158.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6164b46a245ea49750c78f50197de79d6616fae1971adb4080e6ca92f6dbf7a2", size = 759467, upload-time = "2026-07-21T08:32:06.555Z" }, - { url = "https://files.pythonhosted.org/packages/59/93/2d6ca26f64da536cac8f1baa754fe3917bc4af0a9d5e05123338de0eb816/hypothesis-6.158.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f700cc2d89086f1e2491aee5801aba1e9753336728fbcf0fb7cd8334049de2d1", size = 1088322, upload-time = "2026-07-21T08:32:37.446Z" }, - { url = "https://files.pythonhosted.org/packages/a7/07/f8027f6b104dc64ab65da195b2afb35826349845d8b0e15931171fef5614/hypothesis-6.158.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c78ff466723b32af767a0113dddedfc540684769dde48d9c1ffaf3068b5f9fb", size = 1137871, upload-time = "2026-07-21T08:32:41.397Z" }, - { url = "https://files.pythonhosted.org/packages/f6/99/1875204b760c7c774d618d9f16140eaa11e6e36096d3a08a1b3e6e8168db/hypothesis-6.158.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:03deb8b9e6c3eb84f73ce8dda9f117508b09a1350636edf4f503ddff4c0209f6", size = 1262280, upload-time = "2026-07-21T08:31:47.528Z" }, - { url = "https://files.pythonhosted.org/packages/fc/0c/c739cca6b184cdc8d51b2c86c564cb50c928daa2d3ce4a946e139d41fe68/hypothesis-6.158.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0c83df6628fb122cf0ab65727fb2b9b378e95f8e01047d8af49fc172689826c2", size = 1304638, upload-time = "2026-07-21T08:32:13.654Z" }, - { url = "https://files.pythonhosted.org/packages/4f/73/1772915122a5e195ace3fb7d429330c9668aec5f7d712218cf7cc43459dc/hypothesis-6.158.0-cp310-cp310-win_amd64.whl", hash = "sha256:54a729d59aa8f7d07d28094060ff51717050355789a72de168f9779fab50bd69", size = 654907, upload-time = "2026-07-21T08:31:54.515Z" }, - { url = "https://files.pythonhosted.org/packages/02/54/3546b551986265fe5d1edcb66ecae163ef38c30664386d23bc7abf9a76b8/hypothesis-6.158.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e81816c2ab2554cc37bfcd99cb4c91ee930c4b99facf87edd4cc895b206e6a7b", size = 763500, upload-time = "2026-07-21T08:31:34.771Z" }, - { url = "https://files.pythonhosted.org/packages/52/1c/bba2b751ebd4c6ae673129a269e53a4afd854f2c64a21bd6254cbd6dba29/hypothesis-6.158.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:103f8a6fdeb5010d440a1698a535d86c3cd9e69e37be072bf2dc22f7bbb4bdc1", size = 759282, upload-time = "2026-07-21T08:31:45.772Z" }, - { url = "https://files.pythonhosted.org/packages/6e/aa/81529917e0abe3854857fb85b66da0a5e80d571ec491f6a966c78e2a937a/hypothesis-6.158.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fffcd4ea3dbb6268ea75d3e9f4dde48eab0ce3d0d4d5019119f974835721e3b8", size = 1088185, upload-time = "2026-07-21T08:31:28.637Z" }, - { url = "https://files.pythonhosted.org/packages/8b/dc/f35581ef57e48b3b77afe604b80dab48d774f11311866ded190bb02cada8/hypothesis-6.158.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04c94250ca7d38cdf7320914bed98af4fab5c4a3f32c231d0959654691169ab5", size = 1137655, upload-time = "2026-07-21T08:32:25.962Z" }, - { url = "https://files.pythonhosted.org/packages/17/cd/fd02f654a816cc5e9189f220598955c2ddf768dba7cc6008510ff9af6c15/hypothesis-6.158.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0cf564bee3da8716203dfa6ee4be43827f87ceee1c457562b3806faa5ccd2cb2", size = 1261957, upload-time = "2026-07-21T08:32:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/56/ed/8f9a1a147402d5117e42b9942b8e100f3e8b22a6aaea2553b2a99769f560/hypothesis-6.158.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9650154c1d3c5747f279aa6c66224fc93ca93590248622395e954216e6a9a6d", size = 1304661, upload-time = "2026-07-21T08:31:31.652Z" }, - { url = "https://files.pythonhosted.org/packages/07/24/d48c6f39de75b93303104f9d70a621caeceb540eb411684a3e1af8cb735b/hypothesis-6.158.0-cp311-cp311-win_amd64.whl", hash = "sha256:b0f271f47f4ee40362c2b8bac7d06647cfe6a9e93d39008a007039441863be96", size = 654727, upload-time = "2026-07-21T08:31:36.164Z" }, - { url = "https://files.pythonhosted.org/packages/69/6b/de7043f98393b244dac8b840968f08c764221cfa93a1d3585f87ff1f7079/hypothesis-6.158.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:c5e2eead0270c5144aa69c81225a140b2e0f7c84da9497e957c57fef3172adf4", size = 764629, upload-time = "2026-07-21T08:32:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1b/b0a49018a268e4d3d2f4fb781f8af9a1f2cb46b694c03bedf7eda5146b11/hypothesis-6.158.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab82549afe2b514e3bf01f4dd5bc17143376f4cdc496eed0dd81faf7da4d0a46", size = 756262, upload-time = "2026-07-21T08:31:37.637Z" }, - { url = "https://files.pythonhosted.org/packages/fd/7a/b1481cdf5eadda97ced31e0ad3972f3390d7ae6c893309c3f600b74f0212/hypothesis-6.158.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b087dd6e301d91db25b2f7647d6eb931126f608d54f79087d4892183959d768", size = 1086611, upload-time = "2026-07-21T08:31:30.123Z" }, - { url = "https://files.pythonhosted.org/packages/fe/aa/953166c303ebe2ba53ee6250145489464c5c7489423d0732833f0f071c97/hypothesis-6.158.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc4a11e324330ff84744a708e2c35bccae0807dd459fbc8ea8c33429f9b8980", size = 1136681, upload-time = "2026-07-21T08:31:57.633Z" }, - { url = "https://files.pythonhosted.org/packages/c6/bd/039790614f942b85066e90c7ccfb3565d675748d56dad29f95e665892e9f/hypothesis-6.158.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fa777442b00878c0102d7f7d4f61f9fe0482eab90b01011b4b42d68578a9c89f", size = 1259394, upload-time = "2026-07-21T08:32:04.927Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b3/9cf3d28a4f4a18d6c56b4c1ddb243fbb7bb8fec71c9d8f59ef27a5dd557f/hypothesis-6.158.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c05e31750b47152cb95b450b7a97d95652b214da24759060e8eeaa7114b67356", size = 1303672, upload-time = "2026-07-21T08:32:22.429Z" }, - { url = "https://files.pythonhosted.org/packages/2a/de/7b5cdb8391526eaa2ed9a81e9244655143c375c22caabc0649efb6cf56ef/hypothesis-6.158.0-cp312-cp312-win_amd64.whl", hash = "sha256:daf29de47ef4646c3cac1c4c6191bc60131d1efc0c23dc4aac6b734eeed2b616", size = 652164, upload-time = "2026-07-21T08:32:29.747Z" }, - { url = "https://files.pythonhosted.org/packages/8d/bb/db0f522e77384b099453712abbfea5c80c54d3cb75036a0049d9b4c62417/hypothesis-6.158.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:24c0691f6e81b0dbec794c56394065ff1aacf2990b0eda5e3e05ffee3b73230b", size = 764503, upload-time = "2026-07-21T08:31:27.129Z" }, - { url = "https://files.pythonhosted.org/packages/ab/97/069e1f48bbea2e0b51d6a69e7ac93f66a130bfe42244951ee8b6842bbcc5/hypothesis-6.158.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5185b4eafc47854702b1fb82f4afba62bfccbfe88fb1817a175736146999ec3b", size = 756163, upload-time = "2026-07-21T08:32:11.985Z" }, - { url = "https://files.pythonhosted.org/packages/46/c1/4750ae103a3092ede085582d5c5ec91f6f9d434261de6e91b9f8f0fc8b8d/hypothesis-6.158.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac71db149183070efa16255680db8a0f0700bf7017c765dac0ad798d61cb6713", size = 1086523, upload-time = "2026-07-21T08:31:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6b/476db4392eb1204416cd468c192ab14ccb42f37b5dae4c3ae470d2773b78/hypothesis-6.158.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ee998b9568fc223276993f16349232b5e04f303acc747ef783d3837eb2660e3", size = 1136497, upload-time = "2026-07-21T08:31:59.475Z" }, - { url = "https://files.pythonhosted.org/packages/11/62/1a163fbf1e047c1852e9c97a6d8d1bf28adb66f39b05c2d19e5ee84f425c/hypothesis-6.158.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9262957d64ad7beb93d7dccfec239a23546a38896c96bd5f7439e561df502de0", size = 1259413, upload-time = "2026-07-21T08:32:01.109Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/ab04fb53df2bd0976a054e124e53cbe0f9eae972fd5e59465df159377820/hypothesis-6.158.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e767be7d29251a85fa81a028054e844991db554a0d307c75ba2e63154b398acf", size = 1303393, upload-time = "2026-07-21T08:32:31.672Z" }, - { url = "https://files.pythonhosted.org/packages/e9/21/b626bfad3d5b7942fc5978f128ef9be52d9fda59b3664ffdd454b734afa4/hypothesis-6.158.0-cp313-cp313-win_amd64.whl", hash = "sha256:a691d14a2e704dba9cdee02cc8408f9959848195c18284aa3d1fba936f444b60", size = 652111, upload-time = "2026-07-21T08:31:23.089Z" }, - { url = "https://files.pythonhosted.org/packages/32/ff/40effdf103b165c4d21c0be539e25776d7dbafc99a4d34c23e9b5e3734da/hypothesis-6.158.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9319c552321a16547dfb35e4d06a3cc28718d42b2c85e233bb9ac4c27806443d", size = 764702, upload-time = "2026-07-21T08:32:16.895Z" }, - { url = "https://files.pythonhosted.org/packages/12/97/8026d08ba6c5c9ddccc341b84a0340014f5c68a97b3bab6c5b1b31bc2859/hypothesis-6.158.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9ad62e592b568ed347590eb67fc599335f6f48069679b736e031ab0867476f8b", size = 756312, upload-time = "2026-07-21T08:32:39.425Z" }, - { url = "https://files.pythonhosted.org/packages/74/f6/b0603f2f7675d95fd106c9f82626d3388f56999e3f3f84a1f86bd545d31b/hypothesis-6.158.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cdac8cd8e585ed449f6e82a140eb8234d8afd956f4a39b2214cb35e27e0c4e3", size = 1087076, upload-time = "2026-07-21T08:32:27.899Z" }, - { url = "https://files.pythonhosted.org/packages/25/7a/4a1b5acc30364bcc264035bba8c3fd6f5ab026e1eb356bb828a490555bd4/hypothesis-6.158.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d24255c36dd02c3c3556174500d0a1bef0e65f6b7c623830fc758d15c74ee2", size = 1136687, upload-time = "2026-07-21T08:31:49.658Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ec/17c0cc32c9adbe76621e4f1af4e0fc71cbe625f7ce220e93ef647831b7a8/hypothesis-6.158.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33b1863a18b034c7365692ba7a58c95b10b4f7dbcb29f417e25a608faf2bff2e", size = 1259844, upload-time = "2026-07-21T08:31:42.415Z" }, - { url = "https://files.pythonhosted.org/packages/13/11/cd068e62a0a57b4a4f497fe04184d8b7e4bb088b04fbcbb4adc8b393e80b/hypothesis-6.158.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe2b9b4728441f0c896686fff162b6a2ead20a5812f91a523dbe905fcf01bc9", size = 1303709, upload-time = "2026-07-21T08:32:10.067Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/2bd1d151c5efec6941980f1124aef7014c39014a8b5946d7be4161521ba1/hypothesis-6.158.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:46e9935aadd404c46f9f0689cdabc3af30117d12e223627df9309eb136675d50", size = 596208, upload-time = "2026-07-21T08:32:33.391Z" }, - { url = "https://files.pythonhosted.org/packages/03/d4/b772e937e1c6810163a7a2db7354e04e96f44d55b77e46e3d1ec5707f3dc/hypothesis-6.158.0-cp314-cp314-win_amd64.whl", hash = "sha256:da6eccd40a69afb219296dc96ed1b0f96c2af361c897ed311b3070a4b7b88911", size = 652050, upload-time = "2026-07-21T08:32:35.224Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e9/17639f3177daabaf2cf925886f1652d9e23c6bf9a86823ed2a08be59bcf1/hypothesis-6.158.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:179fbc1392cf48d7fd8ce2edc16d68cabcb7f94ee3c8a15fb49a5de181e6c791", size = 763283, upload-time = "2026-07-21T08:31:21.87Z" }, - { url = "https://files.pythonhosted.org/packages/88/b5/86ab00e8dec968f26c0ad8c1cf8331b80042811500a994e330c528e315d5/hypothesis-6.158.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:840a5370b2ae7cdcb9b4facb0f075205bb20f4a84e7ca478c30de7f4d302591f", size = 754781, upload-time = "2026-07-21T08:32:52.111Z" }, - { url = "https://files.pythonhosted.org/packages/d1/3b/19ee755ab7921cd983aa9d1a11a722b68400b73eaa3a4c38f65bf24440a3/hypothesis-6.158.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afc3d8c285bb4212a3ab4b43eea0c5ee0238fc7fda9617936836267c2a5895ec", size = 1085637, upload-time = "2026-07-21T08:31:25.85Z" }, - { url = "https://files.pythonhosted.org/packages/a1/1c/07ef6541cc2e70bd19d4c33d6907895e3bfa26089082cda396d67315cd20/hypothesis-6.158.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4b0de456999b38c19dec86b72cde6a62a216117381709de7c167230b4ea6e2a", size = 1135562, upload-time = "2026-07-21T08:31:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/36/f8/50c24781b7d1742617328618f352a4bf62c0d5246225e8cad71969d4cbcc/hypothesis-6.158.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d741275841323b90ec10001c37d41e9c6726b63354949795331a9635f567de9", size = 1258055, upload-time = "2026-07-21T08:32:20.512Z" }, - { url = "https://files.pythonhosted.org/packages/38/67/31c5c7dfc6a8d8cab2159f3260bce3c309ae37b4aafe4ce91685aaece340/hypothesis-6.158.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4ca2ac733eebbfacbf6de277c153375def0555c3fc0aaa097339d54647135097", size = 1302447, upload-time = "2026-07-21T08:31:33.283Z" }, - { url = "https://files.pythonhosted.org/packages/29/09/eefba6abb03a0045ed815e21b81069d2929d3440a5119261a8dee39502b0/hypothesis-6.158.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b752bd1e8ea42593bcd6c1997448c9f8c560e784f386bea6206a219e031f46d", size = 652211, upload-time = "2026-07-21T08:32:45.663Z" }, - { url = "https://files.pythonhosted.org/packages/30/ec/5687248e0227301f6708907bc68e4d1a28dd1e3c2a46fab1cba376a406ad/hypothesis-6.158.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0042857f6d2ee003c66a5fda60e2a0e5292fa255ce1d8c7db823b8ae87ed63fb", size = 764442, upload-time = "2026-07-21T08:32:24.248Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e3/079a600e264746cf6d7aa8bfe18bc5d5765d4f6df15015b9a3a223c0c3db/hypothesis-6.158.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e50ce9d1a1d9eecff664cce87f7bf93add4a540f4c246934744c3b84bc9daaed", size = 760346, upload-time = "2026-07-21T08:32:56.192Z" }, - { url = "https://files.pythonhosted.org/packages/57/80/d81670e90194b186644200b89fe5b3e92bb2ebf252d10e4f289a46d7de50/hypothesis-6.158.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7161bbd0a5667a7d2efdb6e4bf17f2789c5eadd055192908f9c880dcaeaa716", size = 1089168, upload-time = "2026-07-21T08:31:44.346Z" }, - { url = "https://files.pythonhosted.org/packages/77/2e/fb336218c7cd3ca93d6958bf51d4797d1477c1dbc7f2ddb3ee387fd76247/hypothesis-6.158.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61926f0c1ec12711d5fe1d2d3028aee5359576125e4306a8e999debf84d6f36b", size = 1138937, upload-time = "2026-07-21T08:32:02.939Z" }, - { url = "https://files.pythonhosted.org/packages/24/91/63efab9ba92b1dd4f3b82ab1c1ec61a771f626ef7cfca1ea8e962ce06c11/hypothesis-6.158.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:29ba8b7cfe069cc65c2331758ffafe7366d9ad05c039210b28d7c4521dd01977", size = 655847, upload-time = "2026-07-21T08:31:39.197Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f2/55/983b6bc1b6b343a5ff6020388f9d0680ab477be59a731517e6c4a0387100/hypothesis-6.155.7.tar.gz", hash = "sha256:d8d6091753d0669db3c90c5e5b346cb37c72f3dd9378c8413acb1fd5da63f7ea", size = 478291, upload-time = "2026-06-21T05:54:31.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/f8/c151e196d4f397ed9436a071e52666c70a2f021138dea828b0a461e245db/hypothesis-6.155.7-py3-none-any.whl", hash = "sha256:9f634bdb1f9e9b8ab6ba09431cf2deedb750c96978125a6fb3c5a0f6c6db4131", size = 544762, upload-time = "2026-06-21T05:54:29.506Z" }, ] [[package]] @@ -1615,7 +1549,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1657,14 +1591,14 @@ wheels = [ [[package]] name = "jaraco-functools" -version = "4.6.0" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, ] [[package]] @@ -1732,89 +1666,89 @@ wheels = [ [[package]] name = "librt" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, - { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, - { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, - { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, - { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, - { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, - { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, - { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, - { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, - { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, - { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, - { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, - { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, - { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, - { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, - { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, - { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, - { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, - { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, - { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, - { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, - { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, - { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, - { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, - { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, - { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, - { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, - { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, - { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, - { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, - { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, - { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, - { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, - { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, - { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, - { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, - { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, - { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, - { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, - { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, - { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, - { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, - { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, - { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, - { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, - { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, - { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, - { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, - { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, - { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, - { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, - { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/66/c9d88366893b4b0df6b5375c27ebc9f14c43419d9e244b493be20e85bc74/librt-0.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fe3547407bbce45c09885591f90168325c5a31a6795b9a13f6b9ff3d25093d93", size = 144398, upload-time = "2026-06-30T16:12:03.947Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f2/9be1c6da204701163ec3aaedbf893d2f656b363d8fa302af536ce6471eb4/librt-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5925eca673207204a3adca040a91bdd3738fc7ba48da647ccd55732692a35736", size = 148924, upload-time = "2026-06-30T16:12:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/256824ee27649c6e0a693db25d391f97b43b52364f8efb466014a564bbc7/librt-0.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f9ef097a7711465a204454c69658bbb6b2a6be9bdef0eeeba9a042016d00688", size = 479654, upload-time = "2026-06-30T16:12:07.175Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3f/f4adbb3f293a04bd3dc2eb91d814f5b1e221e6b4522585696ba6901a0b9a/librt-0.12.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57abc8b65edf1a8e80e5472c81c108a7527202e5febfda9e00a684dbaeae534e", size = 472318, upload-time = "2026-06-30T16:12:08.758Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b5/362c93f7b43d4ef84a3d5f156c8d4eeddb22badcf5529a1281c387abbbd7/librt-0.12.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e6f53732a8ae5012a3b6ae092da2933be74ec4169d16038f4af87a0019afea", size = 501555, upload-time = "2026-06-30T16:12:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/24/1d/2d6abf059c3a4b88a6668e7bb81af332b14463028ac8f2b08a1212eb1ebc/librt-0.12.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:edb5f06cdb38d6ef9fd7ae06d62962d65c881b5f965d5e8a6c53e59c15ae4338", size = 494118, upload-time = "2026-06-30T16:12:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/39/c1/f91f3094be2c76361d88aca613d8b7586d15b6026714d59d2e3dc0e35f44/librt-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1473ef42263dfee7553a5c460f11730a4409acf0d52629b284eb1e6b13eb460a", size = 516318, upload-time = "2026-06-30T16:12:14.192Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e2/5211af94252458cbed7a6250163dff9c5a84aec29609121c828375a3b319/librt-0.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1d6f69a06295fb6ad8dcf92b4b2d15d211842005e86eedce64d88e0633592f58", size = 522294, upload-time = "2026-06-30T16:12:15.879Z" }, + { url = "https://files.pythonhosted.org/packages/90/9b/de31f5b9fdf7fa3699c4bbbecf82ebd52013d5d6b500b70b07b0ebacbd51/librt-0.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3275d0270cd07ca9c2e140ae4da34e24a0350e98c6e3815dce96ead67cf0487d", size = 502494, upload-time = "2026-06-30T16:12:17.394Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/22c18dff89f3900dddb3e470e6f7febcda37ff3667b73097a848c9a608b2/librt-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a4834462ec68613024d063c7efe9b188e350d40fda9ba937372039883d2a8051", size = 543422, upload-time = "2026-06-30T16:12:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/52/7b/74691b4b55944227245fffef063714e3ab9707ab1111eb0068512b428c7c/librt-0.12.0-cp310-cp310-win32.whl", hash = "sha256:bcf9b55ac089e8cf201d2146833e1097812c15dcea61911e84d6a2904cf78893", size = 97642, upload-time = "2026-06-30T16:12:20.386Z" }, + { url = "https://files.pythonhosted.org/packages/c5/dc/7f8fa369a1f7cc9b090fecd373659ada0e9bab1ae4a3ac9f163eabd04977/librt-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0a122002f7e0d5c93e84465c4b3fe86621402b7b92f1e2bc0784ebe67793112", size = 117583, upload-time = "2026-06-30T16:12:21.829Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ab/628490f42d1eba82f3c7e5821aa62013e6df7f525b7a9e92c048f8d1cc1c/librt-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f13c1e8563102c2b17581cf37fcb2c6dae7ad485ccea93ae46258998c25f9a1", size = 143821, upload-time = "2026-06-30T16:12:23.248Z" }, + { url = "https://files.pythonhosted.org/packages/38/5f/793e8b6f4b6ac16e7d7198478c0af3670606fbb535c768d5f3e954781423/librt-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1ddff067610a122387024c4df527493b909d41e54a6e5b2d0e6c1041d6dfa09", size = 148442, upload-time = "2026-06-30T16:12:24.582Z" }, + { url = "https://files.pythonhosted.org/packages/ad/92/c780fe37a9e0982f3bd8fd9a631d6b95d09a5a7201c6c50366ce843b7e42/librt-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8dc7ebb5f3eec062398e9d0ef1938acd21b589e74286c4a8906d0183318d91b", size = 478276, upload-time = "2026-06-30T16:12:26.101Z" }, + { url = "https://files.pythonhosted.org/packages/41/bb/226d444bc20d7dff4a19ec6c1ff2c13a76385eebddb59c9c00c923b67536/librt-0.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:198de569ea9d5f6f33808f1c00cc3db9de62bf4d6deafa3b052bd08255083038", size = 472337, upload-time = "2026-06-30T16:12:27.83Z" }, + { url = "https://files.pythonhosted.org/packages/12/79/98ac0840ee90a75d4e1155c79062860b12ccca508587ff2119fc086965f2/librt-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e958678a8bca56016aedc891b391c0e0813ea382a874b54a2c1b313c1d232720", size = 502087, upload-time = "2026-06-30T16:12:29.443Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/a6b1a0d080606a7f5f646b79a1496f21d709f8563877759ace9ce5adad73/librt-0.12.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575a6eca68c8437ed4a8e0f534e31d74b562ba1049a0ee4b5f09e114bcc21be1", size = 493202, upload-time = "2026-06-30T16:12:31.077Z" }, + { url = "https://files.pythonhosted.org/packages/69/cf/e1b036b45f2fc272205ee18bf272b47e8d684bf1a75af26db440c7504359/librt-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:86f241c50dc9e9a3f0db6dbb37a607c8205aa87b920802dabbd50b70d40f6939", size = 514139, upload-time = "2026-06-30T16:12:33.032Z" }, + { url = "https://files.pythonhosted.org/packages/40/34/b193b3e6985469a2f8afa86c90012329c86480b6ff4f2e4bd7b5b937e134/librt-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:113417b934fbf38220a9c7fe94578cefbe7dbb047adcb75aa197905af2b13724", size = 519486, upload-time = "2026-06-30T16:12:34.996Z" }, + { url = "https://files.pythonhosted.org/packages/31/9e/7de4947b1695f247c813f833e3c1e7b77b52e52a7dba2c35411cf806b58e/librt-0.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:762f17c0eb6b5d74e269126996cea8a89e35ab6464c5151619163abcd8623ae2", size = 499609, upload-time = "2026-06-30T16:12:36.663Z" }, + { url = "https://files.pythonhosted.org/packages/59/11/f3730e04e758b1fbf215359062ad2d5b6bd0b0ab5ac46b1c140628795be7/librt-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa93b3bd7f7588c628f6e9bf66485d3467fd9a1ccdb8975b770178f39f35697", size = 542205, upload-time = "2026-06-30T16:12:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/710453617eabe20e18433864f335534c8aff63fbc68d8cd9dbc70a3d08f6/librt-0.12.0-cp311-cp311-win32.whl", hash = "sha256:aaa04b44d4fe86d824616b1f9c13e34c7c01ec0c96dd2abc4f59423696f788e2", size = 98067, upload-time = "2026-06-30T16:12:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/401bff50a56e95daf151d911c99adf5732af2190e8f4d11886c9a229103c/librt-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aaeeddb8e7e4ae3bb9f944e0e618418cb91c0071d5ddbfcc3584b3cf59d39f0", size = 118346, upload-time = "2026-06-30T16:12:41.388Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9a/a3a9078fe88bfc2d2d99dcf1c18593938ae830089cf84c3b2532a6c49d63/librt-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:18a2402fa3123ab76ecca670e6fb33038fde7c1e91181b885226ec4d30af2c2c", size = 104760, upload-time = "2026-06-30T16:12:43.112Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, + { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" }, + { url = "https://files.pythonhosted.org/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" }, + { url = "https://files.pythonhosted.org/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" }, + { url = "https://files.pythonhosted.org/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" }, + { url = "https://files.pythonhosted.org/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" }, + { url = "https://files.pythonhosted.org/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" }, + { url = "https://files.pythonhosted.org/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" }, + { url = "https://files.pythonhosted.org/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" }, + { url = "https://files.pythonhosted.org/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" }, + { url = "https://files.pythonhosted.org/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" }, + { url = "https://files.pythonhosted.org/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" }, + { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, ] [[package]] @@ -1903,14 +1837,14 @@ wheels = [ [[package]] name = "markdown-exec" -version = "1.12.3" +version = "1.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/76/c47da8edb6a12b066728432fb3724109d9d91de5331df5073d12d272493f/markdown_exec-1.12.3.tar.gz", hash = "sha256:006b9cac46470a9499797bc9c579305ae4719e0a8e495e5401dfbf1e66ce7fb4", size = 77841, upload-time = "2026-07-07T09:53:13.838Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/73/1f20927d075c83c0e2bc814d3b8f9bd254d919069f78c5423224b4407944/markdown_exec-1.12.1.tar.gz", hash = "sha256:eee8ba0df99a5400092eeda80212ba3968f3cbbf3a33f86f1cd25161538e6534", size = 78105, upload-time = "2025-11-11T19:25:05.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/a7/0279016386d611183ccc508c5688eb1e2133e8182164d7a2c6213b176f69/markdown_exec-1.12.3-py3-none-any.whl", hash = "sha256:48ac12a565f3f4331b1acd9efc48a0773e717eb7ca7c38e23c1d72ee61660de6", size = 37995, upload-time = "2026-07-07T09:53:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/ea/22/7b684ddb01b423b79eaba9726954bbe559540d510abc7a72a84d8eee1b26/markdown_exec-1.12.1-py3-none-any.whl", hash = "sha256:a645dce411fee297f5b4a4169c245ec51e20061d5b71e225bef006e87f3e465f", size = 38046, upload-time = "2025-11-11T19:25:03.878Z" }, ] [[package]] @@ -2246,7 +2180,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.7" +version = "9.7.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -2261,9 +2195,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, ] [package.optional-dependencies] @@ -2336,7 +2270,7 @@ wheels = [ [[package]] name = "mkdocstrings" -version = "1.0.6" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -2346,9 +2280,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, ] [[package]] @@ -2377,7 +2311,7 @@ wheels = [ [[package]] name = "mypy" -version = "2.3.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, @@ -2387,52 +2321,51 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, - { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, - { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, - { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, - { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, - { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, - { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, - { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, - { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, - { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, - { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, - { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, - { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, - { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, - { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, - { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, - { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, - { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, - { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, - { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, - { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, - { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, - { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, - { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, - { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, - { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, - { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, - { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, - { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, - { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -2624,105 +2557,109 @@ wheels = [ [[package]] name = "pillow" -version = "12.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, - { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, - { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, - { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, - { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, - { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, - { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, - { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, - { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, - { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, - { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, - { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, - { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, - { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, - { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, - { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, - { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, - { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, - { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, - { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, - { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, - { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, - { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, - { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, - { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, - { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, - { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, - { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, - { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, - { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, - { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, - { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, - { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, - { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, - { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, - { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, - { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, - { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, - { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, - { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, - { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, - { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, - { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] name = "platformdirs" -version = "4.10.1" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/cd/4f25b2f95b23f5d2c9c1fe43e49841bff5800562149b2666afc09309aa8f/platformdirs-4.10.1.tar.gz", hash = "sha256:ceab4084426fe6319ce18e86deada8ab1b7487c7aee7040c55e277c9ae793695", size = 31678, upload-time = "2026-07-18T03:53:43.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl", hash = "sha256:0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443", size = 22906, upload-time = "2026-07-18T03:53:42.576Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -2787,17 +2724,17 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.6" +version = "7.35.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, ] [[package]] @@ -3072,15 +3009,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "11.0.1" +version = "11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, + { url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" }, ] [[package]] @@ -3238,15 +3175,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.4.4" +version = "1.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/81/58c70036dffeccb7fe7d79d6260c69f7a28272bbd3909c29a01ea9422744/python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3", size = 72212, upload-time = "2026-07-08T23:06:50.691Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe", size = 34181, upload-time = "2026-07-08T23:06:49.402Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, ] [[package]] @@ -3452,27 +3389,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] @@ -3490,73 +3427,73 @@ wheels = [ [[package]] name = "selectolax" -version = "0.4.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/6c/aec38dfee314a38cb7c0940fe055b22f22627b3e0a216772c24372eef3a9/selectolax-0.4.11.tar.gz", hash = "sha256:2b565ddabce6c9a7b73fa28a39acf8f411a084fa2f169234ec2470f552d4421d", size = 4883455, upload-time = "2026-07-15T07:25:30.588Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/45/db8c56bc6a4adacf308f8b429ad3bb3f610d144c5e715760d523d3d4fe78/selectolax-0.4.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3066be35f630e4c4a00cf2c829548574fcba5963735411e6a9ac78bd4ca830ce", size = 2219152, upload-time = "2026-07-15T07:23:41.021Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a7/6753cb42b04b4de4bffe9e3f0af1b896c5aaa8a9cb4d82e68672e1e7de37/selectolax-0.4.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57e71c021d9950113e7d49892eba56014c072c9d86b71c0b3b7cb846ee913e20", size = 2274121, upload-time = "2026-07-15T07:23:43.154Z" }, - { url = "https://files.pythonhosted.org/packages/f9/12/39dae054c13a07199a53f45e4ea8ffe9b639444d3d0cbfe1cc2479e6fdab/selectolax-0.4.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbee8998d426c1b6b78aab7a051009f9de38d77b93d073025ae19f7d71b3ead3", size = 2352814, upload-time = "2026-07-15T07:23:44.883Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3e/f396451d63adf05733563297eee1f366d28c8d27d570488ff008af542898/selectolax-0.4.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75eeddbd3db7e82293a325c210cf4d218d25961870dbb468383c7516e3f577aa", size = 2403249, upload-time = "2026-07-15T07:23:46.599Z" }, - { url = "https://files.pythonhosted.org/packages/6b/69/17211eba5192027edfb8e6c4f81c3c8480aff70f45df11dbfc25804ec036/selectolax-0.4.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:991866e2f45ce85e3ce7a31714e522f620d83b1eca15fe530055108a94e6087e", size = 2357330, upload-time = "2026-07-15T07:23:48.404Z" }, - { url = "https://files.pythonhosted.org/packages/e4/eb/f7ea711b7d3ce031fd274c605911044ba0a02e963174d8ae18e424ac2483/selectolax-0.4.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc8d449adfdef675bcbfc67d5548513271c7ea869e9b71a2e898138bf34a976a", size = 2413043, upload-time = "2026-07-15T07:23:50.146Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e8/fff61f15307a0ab603589a82d1156608b750484a7313d98e6c5a402cd3a7/selectolax-0.4.11-cp310-cp310-win32.whl", hash = "sha256:3abf5aecfd0c314b62c10ce6e9baee2094cb385e2ec154bd1743febd2fcc9bdb", size = 1768012, upload-time = "2026-07-15T07:23:51.722Z" }, - { url = "https://files.pythonhosted.org/packages/15/ee/fd90974836be691908d83a2a545b8e89de5b0c40568c79cf866e55051af8/selectolax-0.4.11-cp310-cp310-win_amd64.whl", hash = "sha256:21359cf4d1261b314895c07321e76aa2334b1967956efba04cc0d223d7817f48", size = 1883819, upload-time = "2026-07-15T07:23:53.256Z" }, - { url = "https://files.pythonhosted.org/packages/36/ca/11c893aa248687fce3e0332b86b373de2493cf8c494e66309fdd1e631a11/selectolax-0.4.11-cp310-cp310-win_arm64.whl", hash = "sha256:96de39e2f28c359cf930547193b2cacf0ab6b9187309e2515461fff9d5a5aa3c", size = 1825030, upload-time = "2026-07-15T07:23:54.821Z" }, - { url = "https://files.pythonhosted.org/packages/78/ac/aeb509fbeaccf339ef66c7ee9e3a203c908e5fffb53cb4deb5aa29a16a41/selectolax-0.4.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:80af1c7345701934769679a83fcc86526c56eef590423cc2d55cc1eb81adfa5f", size = 2223554, upload-time = "2026-07-15T07:23:56.383Z" }, - { url = "https://files.pythonhosted.org/packages/66/5a/804248c189b0eadeeee613dddfaf965d1476cb7e6480222c2ea707d7d89d/selectolax-0.4.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a532f1993f08f627300891751982dd9641ce1bd29618b535232aec9fa023a1c8", size = 2279032, upload-time = "2026-07-15T07:23:58.056Z" }, - { url = "https://files.pythonhosted.org/packages/75/68/058eb65781e25c25d5db2eed4a26f0a8a63251c012def5e20eab1ec11eac/selectolax-0.4.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8166bb8cad8f2eabed664f689b708851078b74dd50dd93e426b291095badc2cb", size = 2358093, upload-time = "2026-07-15T07:23:59.722Z" }, - { url = "https://files.pythonhosted.org/packages/15/42/2150e058273f5afa3669026bef89bf16cd7d76b38adad4f0a537fec34c2c/selectolax-0.4.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad4bbf08af4e2c89f78dd12a7b8265f0924d0434705bd955b5a262297a924452", size = 2409067, upload-time = "2026-07-15T07:24:01.361Z" }, - { url = "https://files.pythonhosted.org/packages/5d/07/24287ec819f8f8c5ccdcf39b3672fd569a6f53acf7c3ed167dd829fc0f70/selectolax-0.4.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:81f21972788df01b83a5940d9b7d62e6e222f80872c2cfaf67927276e8ef4975", size = 2362638, upload-time = "2026-07-15T07:24:02.921Z" }, - { url = "https://files.pythonhosted.org/packages/7d/64/7b5be0d6a53b9be7f0548c5e54fb2ca9b6c59552fc95ae9ac3d7bccd44ae/selectolax-0.4.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9862c1d0435e89e419e0c595084eaa8f6f71c3fb7a968bdff51db595cec07161", size = 2417494, upload-time = "2026-07-15T07:24:04.465Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2a/07c4a7421bb3d547a54bdd11e91a5e6c5fcf0423bf0f58716fa04db9a53c/selectolax-0.4.11-cp311-cp311-win32.whl", hash = "sha256:e2d7e1a2b67a5e8d251733b2c3f978ed26a56d04552472e0a2fb5ea41fa4e663", size = 1767180, upload-time = "2026-07-15T07:24:05.998Z" }, - { url = "https://files.pythonhosted.org/packages/ac/17/6516a608f7d0f258b27ae7ca838a4db2fb72ade6fed21a05b566795d44cf/selectolax-0.4.11-cp311-cp311-win_amd64.whl", hash = "sha256:8da39a07a589fd181b5e8d25f695d7d40d3a1d89e47c2e00c08ece7fff5ddd3d", size = 1884122, upload-time = "2026-07-15T07:24:07.511Z" }, - { url = "https://files.pythonhosted.org/packages/08/28/3d31a7b6aaad9df1c874278d859a8bae025ec26dced1cf5cc12214901151/selectolax-0.4.11-cp311-cp311-win_arm64.whl", hash = "sha256:dcd24bfc4899e4df2bdcbabc973384367686f391e5a2d8ebb229c043f43be82a", size = 1824236, upload-time = "2026-07-15T07:24:08.998Z" }, - { url = "https://files.pythonhosted.org/packages/d7/96/d3b085e0a6bcb1e9a21a62617a826f14c7d569f70fee848579039db276bf/selectolax-0.4.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25d633cddca0fc769e7d890e9e838908fb4a7326eec5e3b23ae42c27f457541d", size = 2251530, upload-time = "2026-07-15T07:24:10.677Z" }, - { url = "https://files.pythonhosted.org/packages/a7/65/21ff78e6050b71f6467e7baad3eb58b935ade210a72e3e339ce9e6f68ac5/selectolax-0.4.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:634db1b7ae1b1f10ee0b0adbef0eee1aec65a3d01f09b75132bc5b043b7623d1", size = 2300794, upload-time = "2026-07-15T07:24:12.322Z" }, - { url = "https://files.pythonhosted.org/packages/a4/60/faa1878ba9bd362e9078f664e570d3085b3cd679aee49c044a03b4530513/selectolax-0.4.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:168b34466dc4f998d7ceb04f218693546d141543e7c5d327f9e006c0799cd62c", size = 2382882, upload-time = "2026-07-15T07:24:13.906Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a6/a556d87dc37e972cc615384df8376e94bc043fba2ef9fbbf68629a1c8d99/selectolax-0.4.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3d4fd42deabfd03cc5c4fce9517e7226054f9de3984759476e6c4a7b7fe187", size = 2431493, upload-time = "2026-07-15T07:24:15.518Z" }, - { url = "https://files.pythonhosted.org/packages/0e/ca/d95beee6453d3837cba042351e216fb62881750ca930b8d29b223e174224/selectolax-0.4.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8f0223a83051ece2591a1d0cf487f1d0719cb76660d7c5de4c0a201b761c5c23", size = 2387809, upload-time = "2026-07-15T07:24:17.648Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b9/449a4626740099a5cebcb112fee82e423dd1d603f43f72e22b802acc1f8c/selectolax-0.4.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dff45ee85409e6126e4900e0db6950a59a7b58b7ff9d667a6a275b0405b87692", size = 2449963, upload-time = "2026-07-15T07:24:19.666Z" }, - { url = "https://files.pythonhosted.org/packages/f3/de/798b416cba8cc05b9382d81724e73abd887d6ebe441edee848bb23773be9/selectolax-0.4.11-cp312-cp312-win32.whl", hash = "sha256:f69c377298a09f571c89af5916ac85f69a9aaedd7312b0bb803a46d2ca2f9048", size = 1763553, upload-time = "2026-07-15T07:24:21.454Z" }, - { url = "https://files.pythonhosted.org/packages/41/da/e9f32bc598cbd50a5b1e947636f13396511e3d916ab603b622a61c84347f/selectolax-0.4.11-cp312-cp312-win_amd64.whl", hash = "sha256:48545b0351b6f92c4ad2fff835e832768d0cb37834766a82f40242e51fc9901d", size = 1881516, upload-time = "2026-07-15T07:24:22.819Z" }, - { url = "https://files.pythonhosted.org/packages/6d/48/5f0e2f9d098333efd3a83ed9dae78b83b6fbb504be1c5a4b95b28e7b5870/selectolax-0.4.11-cp312-cp312-win_arm64.whl", hash = "sha256:daf7a841d1baa795f940200bec45019c4a31020def16e3a4e35485e82ed64167", size = 1817321, upload-time = "2026-07-15T07:24:25.326Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b5/6e0653d45b8d138b3fc37b37780b989761fb486e7c002aa413eb89d3ad64/selectolax-0.4.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5153157ed60d968ed303acbfd2c8762fa0c0462e2663bd04466471c565deb88a", size = 2250779, upload-time = "2026-07-15T07:24:26.769Z" }, - { url = "https://files.pythonhosted.org/packages/53/c5/c367cf0583799d8c32555c4fa3b900b1e8de1aef07fb009c488a615b6ed0/selectolax-0.4.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:386494779e5464e587ed4dc076e1c48c24ebaf2da1e3a249690551d1f97fe8ed", size = 2300206, upload-time = "2026-07-15T07:24:28.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/61/956974dc429e3df99814d1ba5629a324eef366e2116b030fdd5354713402/selectolax-0.4.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47be0e591125484de14ff0c9aaaa814dd4a4019de35eabe360e88169a263a2b5", size = 2382455, upload-time = "2026-07-15T07:24:29.907Z" }, - { url = "https://files.pythonhosted.org/packages/51/f6/626716e2730f396bd81b853b37e9eeddd3a847730efff7548ad6d695c6e8/selectolax-0.4.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8f014f328f6f79a364293bca54d43af1cec776dc10a5302054a54b5fb2d8675", size = 2431069, upload-time = "2026-07-15T07:24:31.55Z" }, - { url = "https://files.pythonhosted.org/packages/48/f6/acb03eb9e468f74fab17c655761179022fed57bfb1b25ff741e8c0c6a06c/selectolax-0.4.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3f6fac361b5f415c409dfd1a07dd0e9a5899d10daba8d88ce16bd552b0e06f2", size = 2387626, upload-time = "2026-07-15T07:24:33.029Z" }, - { url = "https://files.pythonhosted.org/packages/5a/08/e242e5785e049499771ac5e560112396d244e6142348eaf1c70849f83a66/selectolax-0.4.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8121f4cbfe870d9ad24ae418a735d918d55844e905c3270077e97f4e579770f", size = 2449451, upload-time = "2026-07-15T07:24:34.648Z" }, - { url = "https://files.pythonhosted.org/packages/15/5d/b5dfbde64d622cc94d2136edb0caaafb5779ebbd884f6ae9c041d8dfa669/selectolax-0.4.11-cp313-cp313-win32.whl", hash = "sha256:bde121202b33e6041e9d2db1d62e7466b5883fd1c441eb96ff68d3ea015cfcbd", size = 1763387, upload-time = "2026-07-15T07:24:36.239Z" }, - { url = "https://files.pythonhosted.org/packages/6e/90/2888c831ebd473b6c17486d805a16925187c743964bbf895ec421c1cf2ab/selectolax-0.4.11-cp313-cp313-win_amd64.whl", hash = "sha256:5c7a91fbe1a94849d85228897c416ab9b4518bea6b04dce8ef8acd825ec80e9d", size = 1882102, upload-time = "2026-07-15T07:24:37.847Z" }, - { url = "https://files.pythonhosted.org/packages/83/ea/e78be8710bf162b43d6336ee354fbe21ea712284bd0bf58c67e15264862d/selectolax-0.4.11-cp313-cp313-win_arm64.whl", hash = "sha256:597b8e065978be200c598ae6d682496d96fbce14d34b5d519e93cf5b6be5fb60", size = 1817155, upload-time = "2026-07-15T07:24:39.354Z" }, - { url = "https://files.pythonhosted.org/packages/08/5a/ba94f50ca5a6a0af65e8d47147bbe9f6ad11c408fd03c832ea737836d3eb/selectolax-0.4.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:663ff792f92ed749cfcf452ac19aff5da74b05521e7daacb3b74388deb14d117", size = 2266464, upload-time = "2026-07-15T07:24:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/12/fe/f4d7d554cd7db415c831c8fb5a2b6bbbe3bdf5a49c8f417a6093d4618d6c/selectolax-0.4.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d5ce592a92fceeca2694b369a83ad72891a9c356f668718fe7e1c83eea407bb4", size = 2317609, upload-time = "2026-07-15T07:24:42.682Z" }, - { url = "https://files.pythonhosted.org/packages/96/d6/9d702075634c1a38517a8af4242346bf0e65f206703037b56cf8da114eec/selectolax-0.4.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0f56c49161b18621ac452e42e02b0c5c61ba4c21095cfff3990e040bd9a043c", size = 2382277, upload-time = "2026-07-15T07:24:44.331Z" }, - { url = "https://files.pythonhosted.org/packages/84/c3/f541806ec7bdd0ce8ec69351572d2f2b3919264818cd5bb792482684d492/selectolax-0.4.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224682039ca13eb822be626e49a03592ee2b8557bcdc6381e49417a995170c94", size = 2430423, upload-time = "2026-07-15T07:24:45.937Z" }, - { url = "https://files.pythonhosted.org/packages/70/81/533fa254be8e63b1c0fbe261ba4e2c1ca86357a4844b0830a0d7ae0985f9/selectolax-0.4.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bd843540a197a33049a08fd80e59bfeafbaa688e632d53a05a9b65af5e88296f", size = 2404012, upload-time = "2026-07-15T07:24:47.774Z" }, - { url = "https://files.pythonhosted.org/packages/25/5a/3fc3de5bfdc70af07d55bdc17837b5fd4ae6229444868f057085addd9a18/selectolax-0.4.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2b842c829f916fecb51f0f55882eca3e2ad49e85388178f14ae6fe0912be0a57", size = 2466775, upload-time = "2026-07-15T07:24:49.387Z" }, - { url = "https://files.pythonhosted.org/packages/f2/42/62c66067cbd3c360f762ac6964793091ea0371b3527ca2bf90955fb0b6f3/selectolax-0.4.11-cp314-cp314-win32.whl", hash = "sha256:d33e2ed75cc33e7af3fd50521c33e7d8634fae23bc197a6cee6a5015e056eef6", size = 1875717, upload-time = "2026-07-15T07:24:50.996Z" }, - { url = "https://files.pythonhosted.org/packages/14/b5/6d9ed39e909752645798c1469fb9443c0880ede999e63241ee89e91c7a54/selectolax-0.4.11-cp314-cp314-win_amd64.whl", hash = "sha256:e5929cbe3eedfaf51a09ec89642ab5355b703486d43bcf3c8f0c27d6043a488d", size = 1994595, upload-time = "2026-07-15T07:24:53.143Z" }, - { url = "https://files.pythonhosted.org/packages/49/f9/f172cfe8c29e295b9d7bc79e5b071937470f74311cd04dc3090d4166520a/selectolax-0.4.11-cp314-cp314-win_arm64.whl", hash = "sha256:466daca0599408c9d2cad7658a68490facc5c9b8d0f41ac5d17948914f57306f", size = 1928531, upload-time = "2026-07-15T07:24:55.539Z" }, - { url = "https://files.pythonhosted.org/packages/97/e9/6289d23fa4e5ccd5570a31c9180616a2e3c87ec565f7887bcfbca6204b6d/selectolax-0.4.11-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:086ca6f7e4c475bfff871ec1448ae5d342d43d6a2ca2cea65160d01b3a6a75ec", size = 2281363, upload-time = "2026-07-15T07:24:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/06/c4/1fbf3624f9e52dadda8471dfb68eaf6021e819b827cdb62ce878fa28f469/selectolax-0.4.11-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b530a2c4fad7400af27b2b7e0333c1318ecb5f5dc38e8a141dbe3bd81b398fdf", size = 2325491, upload-time = "2026-07-15T07:24:58.969Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ba/25710a259ecb2b66b9168956b768a2651533c8ea813da9decb0e0f3ee39a/selectolax-0.4.11-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3637d21f7fe60fbd6ca3dbc67a1747f6a55a9389114d72f06b5d69ba2beddf01", size = 2387575, upload-time = "2026-07-15T07:25:00.788Z" }, - { url = "https://files.pythonhosted.org/packages/bc/73/331f83e64e3a17478e832308248345d5224957eb7a62dad2e7fc5daa15b3/selectolax-0.4.11-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fad5b1065f73eeaa07ea343cbc548aaa9f9a5c359c3bdd8d98f5d80b61550d1c", size = 2439126, upload-time = "2026-07-15T07:25:02.574Z" }, - { url = "https://files.pythonhosted.org/packages/d0/33/ab29a558dc65d3a1e28c217b62605b5135123ad89f1f825c8b741366e0fc/selectolax-0.4.11-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1da54e42ab99b9191269306e13c0fd67ada1c6654e8dc8d74fac615931dd3c62", size = 2412927, upload-time = "2026-07-15T07:25:04.375Z" }, - { url = "https://files.pythonhosted.org/packages/ae/b6/e774ec9179d7524adf47d7187b3e4e630104e149b2fbcbfe06088a3f4847/selectolax-0.4.11-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:28915b8fa90c1c3cb585858a6d24d433a3f38ea514aea59013bdb0930d9f6025", size = 2475264, upload-time = "2026-07-15T07:25:05.996Z" }, - { url = "https://files.pythonhosted.org/packages/97/14/0b4865125e777c9d852c9e388c1165e2ef4d7f1fb46596b13a1c02153fe7/selectolax-0.4.11-cp314-cp314t-win32.whl", hash = "sha256:1a6deb4464198ac67f32e56c4463aedf3e1d834b458eaac5b5b5b1ef02dcf15e", size = 1898010, upload-time = "2026-07-15T07:25:07.859Z" }, - { url = "https://files.pythonhosted.org/packages/40/1a/88db3237f2fb357119164c4f5a33a659615e3d10dd0f773d092341ee0cc4/selectolax-0.4.11-cp314-cp314t-win_amd64.whl", hash = "sha256:41f388c26304c1d840f5ee5e07c06bb9388ec834d10fec60dc148f22f98efd38", size = 2019721, upload-time = "2026-07-15T07:25:09.471Z" }, - { url = "https://files.pythonhosted.org/packages/37/03/193913c0f3d37c1e8d66ebfa0f139b2f286f70ec285907aa98b44a620447/selectolax-0.4.11-cp314-cp314t-win_arm64.whl", hash = "sha256:9077fa36e99ef4bb801194ff8f492f67279c0562e7cdfa9b4d06f5c010131969", size = 1950774, upload-time = "2026-07-15T07:25:11.533Z" }, +version = "0.4.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/df/d19f9b47eb6c1aa0ddf95259c757c018c39682facb569e81c23e173bbf35/selectolax-0.4.10.tar.gz", hash = "sha256:89764b4d1e32d38e635dfb270a639fc707af4315b863fd161357a517321e5046", size = 4880217, upload-time = "2026-05-26T15:43:06.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/59/442ebebf0d18e0f873101956a8dbe441752f758289c1a780503c6008893f/selectolax-0.4.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b8cc9177f9200687a24ade718f49cff09738add8eea5230445937dc6de322e1b", size = 2210999, upload-time = "2026-05-26T15:41:23.981Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/4591bf79e2e50d91373330fa28af995453e4f93032b81a4f019c2299c7da/selectolax-0.4.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d06ddf24ebcddead2c5a2ed96607f46651db899b8f331a16481d88a91d5c9af", size = 2263622, upload-time = "2026-05-26T15:41:26.229Z" }, + { url = "https://files.pythonhosted.org/packages/61/00/53d8f7c60dac83744eb7dba53517675c9988ff93bf1ffa745a7bd06acf7f/selectolax-0.4.10-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8fc8fc6b8597eb724b647757417080f1ba042eb6098a702d55cc8ab1eca6367", size = 2344526, upload-time = "2026-05-26T15:41:27.618Z" }, + { url = "https://files.pythonhosted.org/packages/52/82/4254fd5cdb78075f1e32d145b2dc286ed4ba42a94e24672fabc5998281a0/selectolax-0.4.10-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd436b0143edb5e1c4f126eeb4647344bedaab9e2a379eddb528ffac33c7adad", size = 2394529, upload-time = "2026-05-26T15:41:29.193Z" }, + { url = "https://files.pythonhosted.org/packages/53/86/5eb10718d3100ee961e2b8624681d1d65741c6a8a38de61f0b57cfbf5b32/selectolax-0.4.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:529333e1d855a0ad3b9414b5a798bb5686f584cb868f1183bb5ec9f3cb5fec4d", size = 2348589, upload-time = "2026-05-26T15:41:30.582Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/676db73dc3273dc0a10da6c9fde7ac3daa610a7a0c46fa84c89f58906616/selectolax-0.4.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0770275c24b900410eab4b08dd2e6ca8508bbe1d1880bfaf7f17123ad8889242", size = 2404267, upload-time = "2026-05-26T15:41:32.278Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/a507ecb4ff757387cc18c0600a5d282c43671b53e705db6d8574ab09249d/selectolax-0.4.10-cp310-cp310-win32.whl", hash = "sha256:3608997f2ef5f1ba80d26da784604335229deeae66e37d67122b48ef2436bf6b", size = 1769573, upload-time = "2026-05-26T15:41:33.959Z" }, + { url = "https://files.pythonhosted.org/packages/90/94/a0c9ffed6816467213783132621c4530520ad17a264b2acb69f5c38341d6/selectolax-0.4.10-cp310-cp310-win_amd64.whl", hash = "sha256:fb27937417ca7e60a5b408e1deb6fe9af691ba16d962101e9cb1e73aeefc1802", size = 1864428, upload-time = "2026-05-26T15:41:36.207Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e5/d9ffa0b10d0414f820760ab599a0f2ab5dab2bcfa15cb61d81c7044a8323/selectolax-0.4.10-cp310-cp310-win_arm64.whl", hash = "sha256:d9c5b4a32e33359bc6919fd9f3259d2ba51c04b8aa5b25e91611d94d4a503020", size = 1816721, upload-time = "2026-05-26T15:41:38.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/cee18c79edc4268b679ff7af60c58918f2c8279de7715998c81ff4f39eff/selectolax-0.4.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b65ce508ed7f3951a2f36f807494c253d0ebe99d2b18ede149f9a97b99be7d7a", size = 2216148, upload-time = "2026-05-26T15:41:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/8a/29/60ddd1570386ea2e13683848b7d21ffbd1d41c921cb88df20cd10fd6679d/selectolax-0.4.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1bb382bd7009676716814ee81327015ab3d472aff0148fccbd20322d37af83d", size = 2270171, upload-time = "2026-05-26T15:41:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/41/e7/7bef2f76dfd0d270899277d07d7418df82623f1985d570f73ad0a0b963a9/selectolax-0.4.10-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48c238f4f2b4ebd3d94ec260363d2bbab7df2825b592a1f9b12d59a9a9e9ee9a", size = 2347593, upload-time = "2026-05-26T15:41:42.391Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f5/fdb59ef99828ee43e2792507bd67f7d5dfd844dbb1ac6512698e16fa8add/selectolax-0.4.10-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975bf1199c80965307168a05c4d88312a62b8151ac27216ed1248fbd175695bb", size = 2399514, upload-time = "2026-05-26T15:41:44.151Z" }, + { url = "https://files.pythonhosted.org/packages/6e/03/46ed77c44e877278723c31ce8fac7942a6909d46a4d50c87cd6d685b21ce/selectolax-0.4.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c74168deeb116a1271e74e7a157e329930b7f54e328b323ce00bf7089f39dc9", size = 2352416, upload-time = "2026-05-26T15:41:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/c7/80/cb89e578a53cf51674c9083e3a9518a45bf5b8af56309cc1fa63cc007703/selectolax-0.4.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:547de570413e22ae5cbc8432d6224dd5a2d5bc66d1a50e34b705e5940d035caa", size = 2409794, upload-time = "2026-05-26T15:41:47.374Z" }, + { url = "https://files.pythonhosted.org/packages/06/af/5346ac7a2053a502687b136c836c0902d0570398e8f71d73182492a3863b/selectolax-0.4.10-cp311-cp311-win32.whl", hash = "sha256:e053064c5e00788a6bcc5427a753e41c058f29e4285bf84a3e663989b7218cdb", size = 1768531, upload-time = "2026-05-26T15:41:49.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/8c/ae5b0f04d1e39bc212f3a3e1cf308a8c5f117f03cd3c089b6a04e8d8fbc7/selectolax-0.4.10-cp311-cp311-win_amd64.whl", hash = "sha256:db236a92e49b27369a98b9e8ac6c97e1534368d83281d097f3e50c2ba597f112", size = 1865401, upload-time = "2026-05-26T15:41:50.695Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b5/7eb96a229cbdd7773c223c7ecfd0b0eead78b330218c6768179ab5daa2d1/selectolax-0.4.10-cp311-cp311-win_arm64.whl", hash = "sha256:746838a34df35ceff5f6154991b09aa6c27f044dc1fe9a4137f8c57e3e9f1203", size = 1816391, upload-time = "2026-05-26T15:41:52.135Z" }, + { url = "https://files.pythonhosted.org/packages/33/26/a7966a2e2667463717c71903851b4c8a434afd3b592e8528bc87efa1cb1e/selectolax-0.4.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c1933f53fe7410777b3373b9900010ae8d392a6d245f019d8f011d12f700e389", size = 2241715, upload-time = "2026-05-26T15:41:53.81Z" }, + { url = "https://files.pythonhosted.org/packages/76/55/0181dfa3cdec4c5db9868ca489fc50b5521a74c1847a0628db6e08bd3359/selectolax-0.4.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40babac4aea579edfb32b74acdacdb4c38bacb1ee3d1c4189f9665c52c67586f", size = 2292823, upload-time = "2026-05-26T15:41:55.692Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/6d821c8de1bcc8be380c4f9efc6688a433796a5c18809b77219e9616a918/selectolax-0.4.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d41b7e95ab0e025053efcba71267af12a6c12bc624e3bdf5dd83c6534f3d696", size = 2375082, upload-time = "2026-05-26T15:41:57.243Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/23903b34869f6721c36a46b8231da202b7af729f6e28b54fbb06f59e5195/selectolax-0.4.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e41ac4a761c5d4589ce9c40866a80fa2c5d413ac6b3af9546b43a2b3a8da18", size = 2422073, upload-time = "2026-05-26T15:41:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/23/f3/85f1ac82944254822d7bc8b20bae674d9bb4fb93fd3e101f6f807c7f075c/selectolax-0.4.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:425288d4f5b18cfb0049fd1d6f3bddfba319657b9547b0e2bd24103b4b69435e", size = 2379122, upload-time = "2026-05-26T15:42:00.089Z" }, + { url = "https://files.pythonhosted.org/packages/47/61/c4e8aa47d25245644cc0ce60ea2eb922f501ccea8f642ba01c5d551e8959/selectolax-0.4.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1ff2585eaf13ddc5c6614b10a7e47679b0c18a78821495e0c448364f8871592e", size = 2441931, upload-time = "2026-05-26T15:42:01.522Z" }, + { url = "https://files.pythonhosted.org/packages/67/73/05eda9364c5f1053f166648a1e69c546e53af0e01901a2f218b32c2a7010/selectolax-0.4.10-cp312-cp312-win32.whl", hash = "sha256:2428f04d2a48ba5f4c182f0d7234a7e38ac799b3358b3063f0ae41f754ee1c4a", size = 1763584, upload-time = "2026-05-26T15:42:03.055Z" }, + { url = "https://files.pythonhosted.org/packages/10/66/abcf20676cd05eeefef58f6644855216575ae3988b719e914d359ffa3104/selectolax-0.4.10-cp312-cp312-win_amd64.whl", hash = "sha256:c4b4b7c5d09a20539d369891332a107a869ee170453254048fbc18f893deb4f9", size = 1859909, upload-time = "2026-05-26T15:42:04.843Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a5/6a84520df37873cbc08af4fd8a719a495d5971a7c370c2ed8512e8391935/selectolax-0.4.10-cp312-cp312-win_arm64.whl", hash = "sha256:98f93b92d23a8feb88efc7c8e692221456bae30dc551d86d376931491152b909", size = 1810017, upload-time = "2026-05-26T15:42:06.561Z" }, + { url = "https://files.pythonhosted.org/packages/37/88/f932da5e018dcec1fa4286414db4798afdd244fbf95a46ac79db018318e1/selectolax-0.4.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3236fc0fbea9e237ee963274ebae700e68b9d6784bf91e0b3693eda63b393fa2", size = 2241128, upload-time = "2026-05-26T15:42:08.34Z" }, + { url = "https://files.pythonhosted.org/packages/9a/10/0a2caaceda0c6cf226fe5e43f6d7b9134207cf61642bed651712c7599646/selectolax-0.4.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b43da45acece07f94e4b0d555e073f1a91314c98cb86d10860a1b291bc498976", size = 2291992, upload-time = "2026-05-26T15:42:09.723Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d5/687dfb8c09110a5986a4f3f8e424b61c0f71716c5784077b76f02e4e81d7/selectolax-0.4.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5c80477a3a93f0ee350d832c6fc764cd2df1299914a816bcd5ed4f0c9701b9b", size = 2374518, upload-time = "2026-05-26T15:42:11.201Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/ac6011d2785f643baf3be4b8910e657e71427d37e41a15a513274b794425/selectolax-0.4.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16055f712bd93507ce61ecac156bb7acf96b2e46c4d4d30c616e810f74f4da6e", size = 2421458, upload-time = "2026-05-26T15:42:12.656Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fa/0cd29c8a629fe890a53ef2db9cf9282dffad9f4d0cf9a41553a87058f82c/selectolax-0.4.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d42566a7e649d4e5461e763a241f46542df2613876422e0530bc59999063f36d", size = 2378999, upload-time = "2026-05-26T15:42:14.011Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d0/3f9ba04dba314c2f3237aca73e5b6c9578d693297bc0c91b2224d48b2455/selectolax-0.4.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6517b40e41ae5cc7756f92d88c59f178eb4a3683c7ce39c66bd5617587f628e5", size = 2441425, upload-time = "2026-05-26T15:42:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/83e49b1b8dd68d4844a85930a6d68cbf14636be222cda03229833b8326be/selectolax-0.4.10-cp313-cp313-win32.whl", hash = "sha256:7c596b424c55ae87003140f55e6aa6f88e060b645781fa69e947ef60691b2bde", size = 1763466, upload-time = "2026-05-26T15:42:17.674Z" }, + { url = "https://files.pythonhosted.org/packages/16/6b/e77507d5aa7d5724c94333fa229694e108c0a4ca88a1c39ea9ae9ed7afc3/selectolax-0.4.10-cp313-cp313-win_amd64.whl", hash = "sha256:1bb589f6ed0f1ec28784c2cd29de111a5b6f8129c3ecf0e71b9665e588667b97", size = 1861934, upload-time = "2026-05-26T15:42:19.194Z" }, + { url = "https://files.pythonhosted.org/packages/23/69/7e31bb9427fee2d7506d20da60257ec3f72c278c176dbf47b7e0c494d521/selectolax-0.4.10-cp313-cp313-win_arm64.whl", hash = "sha256:ec333fe02c4b7d8a03c0aa58c7c2265edd3312b1ef309f03efd020f595f6dae1", size = 1810021, upload-time = "2026-05-26T15:42:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/5f/21/e48766fb5f921d4a84456a87135a0605d72678753b912be3fd25344f104d/selectolax-0.4.10-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a95f67ed9d5947562e9268332cc7165660c7db0cd3faea959e13b6901b4d323f", size = 2256784, upload-time = "2026-05-26T15:42:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/df/a5/121f398a2ff01a5947b5601ed16f99e29771dbc21b23b18c8f4527f99a40/selectolax-0.4.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01c1354b158f8c87b72ab50a12b4b6d7b276150ded39210d1078d65d1e24ae0d", size = 2308754, upload-time = "2026-05-26T15:42:24.897Z" }, + { url = "https://files.pythonhosted.org/packages/5b/65/3cef0d30585e22c808bc09848e318ccf30b3d00a9123e66ec3d5eb5e5899/selectolax-0.4.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60cabbf1899916a6389fa36f2908b1a76e00dd044f710ae3dd2d0b14919dfc7", size = 2374320, upload-time = "2026-05-26T15:42:26.771Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/99e3598c137d638ff67aba0b53d99649e560c04230c838776328be98fd64/selectolax-0.4.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab8f95b196b2dfb2be3ea7274673d45bb251ec2e16a0e7a3c1fc21c1c20d0722", size = 2420463, upload-time = "2026-05-26T15:42:28.328Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/c17ed28a06b6f9214845719c8fd73edfa21013cd03da31ce9a2d83951259/selectolax-0.4.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a96d59ba2a8ba01e4f732913816f5684d30d0646b7cd0fa17377fc9d1032cc0d", size = 2395397, upload-time = "2026-05-26T15:42:29.774Z" }, + { url = "https://files.pythonhosted.org/packages/14/60/b1bd8724aa041b233a32d9eba3135fd7d4f285b1f216c39b5ba7263680d4/selectolax-0.4.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:794a10f0c2cb9662ad85c5a970a72ca072057d68f7f0b3cf7b3230e5f2a8f221", size = 2458486, upload-time = "2026-05-26T15:42:31.592Z" }, + { url = "https://files.pythonhosted.org/packages/04/7d/a964ceb566a6042170bbb42bd4f22117a13afe15db285c0ab858477c5963/selectolax-0.4.10-cp314-cp314-win32.whl", hash = "sha256:5d5b5437ce7548e7bc0b7712114de07dd0e4f94a30b06c968a6344148621df50", size = 1875157, upload-time = "2026-05-26T15:42:32.959Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e1/0a1f9004a48d1229d90ea42efcd9093688c3afc852c2d122245c499e5821/selectolax-0.4.10-cp314-cp314-win_amd64.whl", hash = "sha256:ab07fc342cf477c0320d22fac52917b824871caf5ab177a0fd94377a901ab657", size = 1970092, upload-time = "2026-05-26T15:42:34.325Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/1a1d8196ffa9fc84c64a66294ef1ecdbb9951c7a04b9bb6c6d224d776712/selectolax-0.4.10-cp314-cp314-win_arm64.whl", hash = "sha256:8b57c64690e3c86b5d07386e2e598f4d3b4990144b1d5717db7038d0b675a97e", size = 1920951, upload-time = "2026-05-26T15:42:35.627Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9b/74df013d85601d21c5e79682576ead17abe660ffce39a6fb7c300494f829/selectolax-0.4.10-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:3c21749e3d252419581160f3a3e43c1ad95dd0f83a13fe0d8c8fe6256bf7bbe6", size = 2272452, upload-time = "2026-05-26T15:42:37.379Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a4/c4f94d1ae0d3da58a3dc410d0c59a8e37429c74b38287a2ab0ed0480123d/selectolax-0.4.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:88475ef523fe5426d113e8319eeb741806a51cc025840f337661734c65cf1aa4", size = 2318066, upload-time = "2026-05-26T15:42:39.203Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9e/f977b33c18e8957fca9fcbdf445e5501a7dd1afd57b3fc15d07de249e6fc/selectolax-0.4.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40ecd0ff315dffa4a55840107367b0a54f2cd35be66e700c619364e0cd087ace", size = 2379516, upload-time = "2026-05-26T15:42:40.8Z" }, + { url = "https://files.pythonhosted.org/packages/43/4a/3f086f729dd47423719e5101f64342bd637e4420b0dafb3e5df61a9b37c8/selectolax-0.4.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d199a73894368b83e5d744f64bddf80c22a37c160253a8e81c1a6000be607b", size = 2430207, upload-time = "2026-05-26T15:42:42.751Z" }, + { url = "https://files.pythonhosted.org/packages/84/9d/51a5283bb95c679448bc5d3e1da9a00ebd5e3ad983e2247d1a1190ef3ba6/selectolax-0.4.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4b0e1ad8b3d3d11bc173d33bb0fcf9d3ef8c667f1f3debd31ac8e9e3880ee174", size = 2404374, upload-time = "2026-05-26T15:42:44.835Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2b/39cc5599b5531423a4eb68510a395baf8ce8ba9c3655dd86e7b4bc6140ba/selectolax-0.4.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:373298d5c2e22a73740ba8f60a5813fd6bdb8fa2c0fd170841341ad0119bdf6b", size = 2466741, upload-time = "2026-05-26T15:42:46.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/b20bb6b03b5cf8e9ff6d524796942291d9157ccfcdbf6bcc5fc322fd2d2b/selectolax-0.4.10-cp314-cp314t-win32.whl", hash = "sha256:67f826152635521e1751665e315f3be82d027fe79d6446bf8434e0f7069e55db", size = 1923995, upload-time = "2026-05-26T15:42:48.041Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/8fbe188c6c2aa41e10bd83b46e0498b60ed36584f9ce0a7796ca7f7cd34e/selectolax-0.4.10-cp314-cp314t-win_amd64.whl", hash = "sha256:9c4c9afbd28b81892806e9699ecd323656e1eff7318c1245e80cd6bb78566a99", size = 2039696, upload-time = "2026-05-26T15:42:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/e511474facfd324529509626097f981986b7adc935fb4eb436c0812c540f/selectolax-0.4.10-cp314-cp314t-win_arm64.whl", hash = "sha256:68e1ef717b47f5cdcd1b151b2176d7184c38cb3772f8964509979840575203ef", size = 1944297, upload-time = "2026-05-26T15:42:50.906Z" }, ] [[package]] name = "setuptools" -version = "83.0.0" +version = "82.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] [[package]] @@ -3597,11 +3534,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.9" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] @@ -3672,11 +3609,11 @@ wheels = [ [[package]] name = "tomlkit" -version = "0.15.1" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, ] [[package]] @@ -3769,11 +3706,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.16.0" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -3807,11 +3744,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2026.3" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] @@ -3878,7 +3815,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.6.1" +version = "21.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -3887,9 +3824,9 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/d9/b477fddb68840b570af8b22afe9b035cbc277b5fb7b33dea390617a8b10f/virtualenv-21.6.1.tar.gz", hash = "sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128", size = 5526620, upload-time = "2026-07-10T19:33:53.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/7c/4e7225d46d634a0d8d534dd8a6ce0c319d09b4d0cf0337eb314ca4789d8c/virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b", size = 5506392, upload-time = "2026-07-10T19:33:51.629Z" }, + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, ] [[package]] @@ -3979,14 +3916,14 @@ wheels = [ [[package]] name = "wcmatch" -version = "11.0" +version = "10.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bracex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/25/1da725838132221e33568973da484ff43813662ccc06ebf7f6e3abddfcd5/wcmatch-11.0.tar.gz", hash = "sha256:55d95c2447789712774b198ceec72939e88b5618f1f8f0a9b605bf7740b63b96", size = 141360, upload-time = "2026-07-10T05:50:24.183Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/eb989c3113908e2ef46d940a53695a1ebb4be5a732c4a4f700be8f8d682b/wcmatch-10.2.tar.gz", hash = "sha256:92204839e3e9c945e1e71d7e1e4edeab2601ed50a5c51ff4f3f97ca711eeb738", size = 132499, upload-time = "2026-06-30T00:50:07.198Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/12/f38b6fee116274d7221743caab07d765032e1370bb54cad8714f87aeb0e8/wcmatch-11.0-py3-none-any.whl", hash = "sha256:3a5977ace27e075eef67eb03d539563f1a19018b62881949a42932cf66926934", size = 42914, upload-time = "2026-07-10T05:50:22.995Z" }, + { url = "https://files.pythonhosted.org/packages/d4/73/aef4aaf16b8d785762e2b14cf321a9178cc84a1bf4c40f58320f499a2d65/wcmatch-10.2-py3-none-any.whl", hash = "sha256:f1a79e80ccbe296907b7eaf57d8d3bc49eab0b428d35f7d09986b5079b6e4a5d", size = 39742, upload-time = "2026-06-30T00:50:05.927Z" }, ] [[package]] From 9258c37ad16077ba1c2666b41be1f1994ab76c61 Mon Sep 17 00:00:00 2001 From: Alexandre Date: Fri, 7 Aug 2026 15:57:01 +0200 Subject: [PATCH 2/2] refactor(services): unify ContextFile and ContextStorage into Context enum - Replace ContextFile and ContextStorage with a single Context enum in digitalkin.models.services.services - Remove models/services/filesystem.py, now superseded by services.py - Update storage and filesystem strategies (default, grpc) to use the new shared Context type instead of separate per-service enums - Update grpc_filesystem and grpc_storage tests to match the new type - Update 1.0.2.dev6 and 1.0.2.dev7 changelogs to document the rename --- docs/changelog/1.0.2.dev6.md | 30 +-- docs/changelog/1.0.2.dev7.md | 36 ++-- src/digitalkin/models/services/filesystem.py | 19 -- src/digitalkin/models/services/services.py | 16 ++ src/digitalkin/models/services/storage.py | 20 +- .../services/filesystem/default_filesystem.py | 4 +- .../filesystem/filesystem_strategy.py | 8 +- .../services/filesystem/grpc_filesystem.py | 14 +- .../services/storage/grpc_storage.py | 37 +++- .../services/storage/storage_strategy.py | 45 ++-- .../filesystem/test_grpc_filesystem.py | 81 +++++-- tests/services/storage/test_grpc_storage.py | 204 +++++++++++++++++- 12 files changed, 387 insertions(+), 127 deletions(-) delete mode 100644 src/digitalkin/models/services/filesystem.py diff --git a/docs/changelog/1.0.2.dev6.md b/docs/changelog/1.0.2.dev6.md index 1ed6fc7a..0369be38 100644 --- a/docs/changelog/1.0.2.dev6.md +++ b/docs/changelog/1.0.2.dev6.md @@ -8,7 +8,7 @@ capabilities landed: 1. **Record visibility** — every storage record now carries a read-access scope (`Visibility`) that is independent of write ownership. You can tag a record `PUBLIC` / `PRIVATE` / `INTERNAL` on write and filter by it on list. 2. **Context scopes** — the old string `scope: Literal["mission", "setup"]` - argument on the storage service was replaced by a typed `ContextStorage` + argument on the storage service was replaced by a typed `Context` enum, and the enum was extended with two **read-only cross-owner** scopes, `USERS` and `ORGANIZATIONS`. `dev6` completes the client so those scopes are actually emitted on the wire — a kin can now list records shared by other kins of the same user/organization. @@ -23,17 +23,19 @@ from request metadata; the client only sends the context *kind*. ## What changed -### `ContextStorage` — the owner/scope of an operation (replaces `scope`) +### `Context` — the owner/scope of an operation (replaces `scope`) ```python -from digitalkin.models.services.storage import ContextStorage + +from digitalkin.models.services.services import Context + class ContextStorage(Enum): - UNSPECIFIED = "unspecified" - MISSIONS = "missions" # this mission (default) - SETUP_VERSIONS = "setup_versions" # this setup version (shared across missions) - USERS = "users" # read-only: all kins of the same user - ORGANIZATIONS = "organizations" # read-only: all kins of the same organization + UNSPECIFIED = "unspecified" + MISSIONS = "missions" # this mission (default) + SETUP_VERSIONS = "setup_versions" # this setup version (shared across missions) + USERS = "users" # read-only: all kins of the same user + ORGANIZATIONS = "organizations" # read-only: all kins of the same organization ``` - `MISSIONS` (default) and `SETUP_VERSIONS` are **read/write** owner contexts. @@ -96,7 +98,8 @@ All examples assume you have a storage strategy (e.g. `context.storage` inside a ### Write a record with a visibility ```python -from digitalkin.models.services.storage import ContextStorage, Visibility +from digitalkin.models.services.storage import Visibility +from digitalkin.models.services.services import Context # Readable by every kin of the same user, owned by this mission await storage.store( @@ -111,7 +114,7 @@ await storage.upsert( "shared_config", "defaults", {"lang": "fr"}, - context=ContextStorage.SETUP_VERSIONS, + context=Context.SETUP_VERSIONS, visibility=Visibility.INTERNAL, ) ``` @@ -164,7 +167,7 @@ records = await storage.list("reports", context=ContextStorage.ORGANIZATIONS) - **`scope=` → `context=`**: replace every `scope="mission"` / `scope="setup"` string argument with `context=ContextStorage.MISSIONS` / `context=ContextStorage.SETUP_VERSIONS`. The parameter was renamed and retyped from a `str` literal to the - `ContextStorage` enum, so passing the old string raises `TypeError`. + `Context` enum, so passing the old string raises `TypeError`. - **`data_type`**: pass the `DataType` enum (e.g. `DataType.OUTPUT`), not a string — `data_type="OUTPUT"` no longer works. - **New optional args**: `visibility` (on `store`/`update`/`upsert`) and @@ -181,9 +184,10 @@ await storage.list("reports", scope="setup") await storage.store("reports", "r1", data, data_type="OUTPUT") # after (>= 1.0.2.dev6) -from digitalkin.models.services.storage import ContextStorage, DataType +from digitalkin.models.services.storage import DataType +from digitalkin.models.services.services import Context -await storage.list("reports", context=ContextStorage.SETUP_VERSIONS) +await storage.list("reports", context=Context.SETUP_VERSIONS) await storage.store("reports", "r1", data, data_type=DataType.OUTPUT) ``` diff --git a/docs/changelog/1.0.2.dev7.md b/docs/changelog/1.0.2.dev7.md index 42738727..5182eb4f 100644 --- a/docs/changelog/1.0.2.dev7.md +++ b/docs/changelog/1.0.2.dev7.md @@ -4,7 +4,7 @@ This release brings the **filesystem** service in line with the storage context model shipped in `dev6`. The string `context: Literal["mission", "setup"]` -argument is replaced by a typed `ContextFile` enum, extended with the two **read-only cross-owner** scopes `USERS` and +argument is replaced by a typed `Context` enum, extended with the two **read-only cross-owner** scopes `USERS` and `ORGANIZATIONS`. A file produced by one kin can now be read by another kin of the same user/organization, subject to server-side access control. @@ -20,17 +20,19 @@ user / organization) is resolved server-side from the request context. ## What changed -### `ContextFile` — the owner/scope of a filesystem operation (replaces `scope`/`context` strings) +### `Context` — the owner/scope of a filesystem operation (replaces `scope`/`context` strings) ```python -from digitalkin.models.services.filesystem import ContextFile + +from digitalkin.models.services.services import Context + class ContextFile(Enum): - UNSPECIFIED = "unspecified" - MISSIONS = "mission" # this mission (default) - SETUP = "setup" # this setup version - USERS = "user" # read-only: all kins of the same user - ORGANIZATIONS = "organization" # read-only: all kins of the same organization + UNSPECIFIED = "unspecified" + MISSIONS = "mission" # this mission (default) + SETUP = "setup" # this setup version + USERS = "user" # read-only: all kins of the same user + ORGANIZATIONS = "organization" # read-only: all kins of the same organization ``` - `MISSIONS` (default) and `SETUP` are the read/write owner contexts. @@ -62,28 +64,29 @@ default context stays `MISSIONS`. ## How to use ```python -from digitalkin.models.services.filesystem import ContextFile + +from digitalkin.models.services.services import Context from digitalkin.services.filesystem.filesystem_strategy import FileFilter # Read a file owned by the current mission (default) record = await filesystem.get_file(file_id, include_content=True) # Read a file from the setup-version scope -record = await filesystem.get_file(file_id, context=ContextFile.SETUP) +record = await filesystem.get_file(file_id, context=Context.SETUP) # Cross-owner: list files shared by other kins of the same user. # The server resolves the concrete user id; no id is sent by the client. records, total = await filesystem.get_files( - FileFilter(context=ContextFile.USERS, prefix="reports/"), + FileFilter(context=Context.USERS, prefix="reports/"), ) # Same across the whole organization -records, total = await filesystem.get_files(FileFilter(context=ContextFile.ORGANIZATIONS)) +records, total = await filesystem.get_files(FileFilter(context=Context.ORGANIZATIONS)) ``` ## Migration -- **`context="mission"` / `context="setup"` → `ContextFile`**: pass +- **`context="mission"` / `context="setup"` → `Context`**: pass `ContextFile.MISSIONS` / `ContextFile.SETUP` to `get_file`. For `FileFilter`, the legacy strings still validate (Pydantic coerces them by value), but prefer the enum for clarity. - **No change to write calls** (`upload_files` / `update_file` / `delete_files`). @@ -97,9 +100,10 @@ await filesystem.get_file(file_id, context="setup") await filesystem.get_files(FileFilter(context="mission", prefix="x/")) # after -from digitalkin.models.services.filesystem import ContextFile -await filesystem.get_file(file_id, context=ContextFile.SETUP) -await filesystem.get_files(FileFilter(context=ContextFile.MISSIONS, prefix="x/")) +from digitalkin.models.services.services import Context + +await filesystem.get_file(file_id, context=Context.SETUP) +await filesystem.get_files(FileFilter(context=Context.MISSIONS, prefix="x/")) ``` ## Verification diff --git a/src/digitalkin/models/services/filesystem.py b/src/digitalkin/models/services/filesystem.py deleted file mode 100644 index f09f5faa..00000000 --- a/src/digitalkin/models/services/filesystem.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Filesystem service models.""" - -from enum import Enum - - -class ContextFile(Enum): - """Owner/scope of a file in the filesystem service. - - Mirrors the filesystem proto context kinds. MISSIONS/SETUP are the read/write - owner contexts this strategy operates on; USERS/ORGANIZATIONS are read-only - cross-owner scopes whose concrete id is resolved server-side from the request - metadata (the client sends only the kind). - """ - - UNSPECIFIED = "unspecified" - MISSIONS = "mission" - SETUP = "setup" - USERS = "user" - ORGANIZATIONS = "organization" diff --git a/src/digitalkin/models/services/services.py b/src/digitalkin/models/services/services.py index cdc057a0..316be0de 100644 --- a/src/digitalkin/models/services/services.py +++ b/src/digitalkin/models/services/services.py @@ -8,3 +8,19 @@ class ServicesMode(str, Enum): LOCAL = "local" REMOTE = "remote" + + +class Context(Enum): + """Owner/scope of a file in the filesystem service. + + Mirrors the filesystem proto context kinds. MISSIONS/SETUP are the read/write + owner contexts this strategy operates on; USERS/ORGANIZATIONS are read-only + cross-owner scopes whose concrete id is resolved server-side from the request + metadata (the client sends only the kind). + """ + + UNSPECIFIED = "unspecified" + MISSIONS = "mission" + SETUP = "setup" + USERS = "user" + ORGANIZATIONS = "organization" diff --git a/src/digitalkin/models/services/storage.py b/src/digitalkin/models/services/storage.py index 783a54fd..da3be7ff 100644 --- a/src/digitalkin/models/services/storage.py +++ b/src/digitalkin/models/services/storage.py @@ -53,24 +53,14 @@ class DataType(Enum): OTHER = "OTHER" -class ContextStorage(Enum): - """Enum defining the context of data in storage.""" - - UNSPECIFIED = "unspecified" - MISSIONS = "missions" - SETUP_VERSIONS = "setup_versions" - USERS = "users" - ORGANIZATIONS = "organizations" - - class Visibility(Enum): - """Read-access scope of a record, mirroring the storage proto by integer value. + """Read-access scope of a record, mirroring the storage proto kinds by name. Ownership (who may edit) stays keyed on the record context; this only governs who may read. UNSPECIFIED lets the storage service apply its server-side default. """ - UNSPECIFIED = 0 - PUBLIC = 1 - PRIVATE = 2 - INTERNAL = 3 + UNSPECIFIED = "unspecified" + PUBLIC = "public" + PRIVATE = "private" + INTERNAL = "internal" diff --git a/src/digitalkin/services/filesystem/default_filesystem.py b/src/digitalkin/services/filesystem/default_filesystem.py index 65293e5d..c8d2d803 100644 --- a/src/digitalkin/services/filesystem/default_filesystem.py +++ b/src/digitalkin/services/filesystem/default_filesystem.py @@ -9,7 +9,7 @@ from anyio import Path as AsyncPath from digitalkin.logger import logger -from digitalkin.models.services.filesystem import ContextFile +from digitalkin.models.services.services import Context from digitalkin.services.filesystem.exceptions import FilesystemServiceError from digitalkin.services.filesystem.filesystem_strategy import ( FileFilter, @@ -207,7 +207,7 @@ async def get_files( async def get_file( self, file_id: str, - context: ContextFile = ContextFile.MISSIONS, # noqa: ARG002 + context: Context = Context.MISSIONS, # noqa: ARG002 *, include_content: bool = False, ) -> FilesystemRecord: diff --git a/src/digitalkin/services/filesystem/filesystem_strategy.py b/src/digitalkin/services/filesystem/filesystem_strategy.py index ca7b4981..ec98fc8a 100644 --- a/src/digitalkin/services/filesystem/filesystem_strategy.py +++ b/src/digitalkin/services/filesystem/filesystem_strategy.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field -from digitalkin.models.services.filesystem import ContextFile +from digitalkin.models.services.services import Context from digitalkin.services.base_strategy import BaseStrategy @@ -30,8 +30,8 @@ class FilesystemRecord(BaseModel): class FileFilter(BaseModel): """Filter criteria for querying files.""" - context: ContextFile = Field( - default=ContextFile.MISSIONS, + context: Context = Field( + default=Context.MISSIONS, description="The context of the files: mission/setup (owner) or user/organization (read-only cross-owner)", ) names: list[str] | None = Field(default=None, description="Filter by file names (exact matches)") @@ -131,7 +131,7 @@ async def upload_files( async def get_file( self, file_id: str, - context: ContextFile = ContextFile.MISSIONS, + context: Context = Context.MISSIONS, *, include_content: bool = False, ) -> FilesystemRecord: diff --git a/src/digitalkin/services/filesystem/grpc_filesystem.py b/src/digitalkin/services/filesystem/grpc_filesystem.py index cff4e543..e75221e6 100644 --- a/src/digitalkin/services/filesystem/grpc_filesystem.py +++ b/src/digitalkin/services/filesystem/grpc_filesystem.py @@ -10,7 +10,7 @@ from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin from digitalkin.logger import logger from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.models.services.filesystem import ContextFile +from digitalkin.models.services.services import Context from digitalkin.services.filesystem.exceptions import FilesystemServiceError from digitalkin.services.filesystem.filesystem_strategy import ( FileFilter, @@ -81,7 +81,7 @@ def _file_proto_to_data(file: filesystem_pb2.File) -> FilesystemRecord: ) @staticmethod - def _context_enum(context: ContextFile) -> filesystem_pb2.ContextFile: + def _context_enum(context: Context) -> filesystem_pb2.ContextFile: """Map a context kind to the wire's context-kind enum. Since dev4 the request carries only the kind; the concrete id is resolved @@ -99,13 +99,13 @@ def _context_enum(context: ContextFile) -> filesystem_pb2.ContextFile: # TODO(validate): remove after prod validation # [VALIDATE CTXENUM] server resolves the concrete id from metadata match context: - case ContextFile.SETUP: + case Context.SETUP: return filesystem_pb2.CONTEXT_SETUP - case ContextFile.MISSIONS: + case Context.MISSIONS: return filesystem_pb2.CONTEXT_MISSIONS - case ContextFile.USERS: + case Context.USERS: return filesystem_pb2.CONTEXT_USERS - case ContextFile.ORGANIZATIONS: + case Context.ORGANIZATIONS: return filesystem_pb2.CONTEXT_ORGANIZATIONS return filesystem_pb2.CONTEXT_UNSPECIFIED @@ -195,7 +195,7 @@ async def upload_files( async def get_file( self, file_id: str, - context: ContextFile = ContextFile.MISSIONS, + context: Context = Context.MISSIONS, *, include_content: bool = False, ) -> FilesystemRecord: diff --git a/src/digitalkin/services/storage/grpc_storage.py b/src/digitalkin/services/storage/grpc_storage.py index 29383e05..88fa189e 100644 --- a/src/digitalkin/services/storage/grpc_storage.py +++ b/src/digitalkin/services/storage/grpc_storage.py @@ -1,7 +1,5 @@ """This module implements the default storage strategy.""" -from typing import cast - from agentic_mesh_protocol.storage.v1 import data_pb2, storage_service_pb2_grpc from google.protobuf.struct_pb2 import Struct from pydantic import BaseModel @@ -10,6 +8,7 @@ from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper from digitalkin.logger import logger from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.services.services import Context from digitalkin.models.services.storage import DataType, Visibility from digitalkin.services.storage.exceptions import StorageServiceError from digitalkin.services.storage.storage_strategy import ( @@ -59,14 +58,34 @@ def _context_enum(self, context: str) -> data_pb2.ContextStorage: # [VALIDATE CTXENUM] server resolves the concrete id (incl. setup->current version) from metadata if context == self.setup_version_id or context.startswith("setup_versions:"): return data_pb2.CONTEXT_SETUP_VERSIONS - if context.startswith("users:"): + if context.startswith(f"{Context.USERS.value}:"): return data_pb2.CONTEXT_USERS - if context.startswith("organizations:"): + if context.startswith(f"{Context.ORGANIZATIONS.value}:"): return data_pb2.CONTEXT_ORGANIZATIONS - if context.startswith("unspecified:"): + if context.startswith(f"{Context.UNSPECIFIED.value}:"): return data_pb2.CONTEXT_UNSPECIFIED return data_pb2.CONTEXT_MISSIONS + @staticmethod + def _visibility_enum(visibility: Visibility) -> data_pb2.Visibility: + """Map an SDK ``Visibility`` to its storage-proto wire enum. + + Args: + visibility: The SDK visibility level. + + Returns: + The matching ``VISIBILITY_*`` wire enum (``VISIBILITY_UNSPECIFIED`` by default). + """ + match visibility: + case Visibility.PUBLIC: + return data_pb2.VISIBILITY_PUBLIC + case Visibility.PRIVATE: + return data_pb2.VISIBILITY_PRIVATE + case Visibility.INTERNAL: + return data_pb2.VISIBILITY_INTERNAL + case _: + return data_pb2.VISIBILITY_UNSPECIFIED + def _build_record_from_proto(self, proto: data_pb2.StorageRecord) -> StorageRecord: """Convert a protobuf StorageRecord message into our Pydantic model. @@ -84,7 +103,7 @@ def _build_record_from_proto(self, proto: data_pb2.StorageRecord) -> StorageReco coll = proto.collection rid = proto.record_id dtype = DataType[data_pb2.DataType.Name(proto.data_type)] - visibility = Visibility(proto.visibility) + visibility = Visibility[data_pb2.Visibility.Name(proto.visibility).removeprefix("VISIBILITY_")] # Selective deserialization: only the nested Struct payload payload = ProtoUtils.proto_to_dict(proto.data) if proto.HasField("data") else {} @@ -147,7 +166,7 @@ async def _store(self, record: StorageRecord) -> StorageRecord: collection=record.collection, record_id=record.record_id, data_type=record.data_type.name, - visibility=cast("data_pb2.Visibility", record.visibility.value), + visibility=self._visibility_enum(record.visibility), ) try: resp = await self.exec_grpc_query("StoreRecord", req) @@ -216,7 +235,7 @@ async def _update( context=self._context_enum(context), collection=collection, record_id=record_id, - visibility=cast("data_pb2.Visibility", visibility.value), + visibility=self._visibility_enum(visibility), ) try: resp = await self.exec_grpc_query("UpdateRecord", req) @@ -279,7 +298,7 @@ async def _list( collection=collection, ) if visibilities: - req.visibilities.extend(cast("data_pb2.Visibility", v.value) for v in visibilities) + req.visibilities.extend(self._visibility_enum(v) for v in visibilities) resp = await self.exec_grpc_query("ListRecords", req) except PermissionDeniedError: # TODO(validate): remove after prod validation diff --git a/src/digitalkin/services/storage/storage_strategy.py b/src/digitalkin/services/storage/storage_strategy.py index 9723ec72..152d5448 100644 --- a/src/digitalkin/services/storage/storage_strategy.py +++ b/src/digitalkin/services/storage/storage_strategy.py @@ -8,7 +8,8 @@ from pydantic import BaseModel, Field -from digitalkin.models.services.storage import ContextStorage, DataType, Visibility +from digitalkin.models.services.services import Context +from digitalkin.models.services.storage import DataType, Visibility from digitalkin.services.base_strategy import BaseStrategy from digitalkin.services.storage.exceptions import StorageServiceError @@ -37,32 +38,32 @@ class StorageStrategy(BaseStrategy, ABC): (setup-version scope). Both attributes are expected to already contain the full prefix (`missions:` / `setup_versions:`). - Public methods accept `scope: Literal["mission", "setup", "user", "organization"]` - (default `"mission"`); internally we resolve it to the matching context string and - pass that to the abstract `_store/_read/_update/_remove/_list/_remove_collection`. - `user`/`organization` are read-only cross-owner scopes usable only for listing. + Public methods accept a `context: Context` kind (default `Context.MISSIONS`); + internally we resolve it to the matching context string and pass that to the + abstract `_store/_read/_update/_remove/_list/_remove_collection`. + `Context.USERS`/`Context.ORGANIZATIONS` are read-only cross-owner scopes usable only for listing. """ - def _resolve_context(self, context: ContextStorage) -> str: + def _resolve_context(self, context: Context) -> str: """Resolve a context kind to its storage context string. - MISSIONS/SETUP_VERSIONS map to the owner contexts this strategy was built - with. USERS/ORGANIZATIONS (read-only cross-owner) and UNSPECIFIED hold no - concrete id here, so they return a kind-only marker (`users:`, - `organizations:`, `unspecified:`); the storage service resolves the id — or - applies its default for UNSPECIFIED — server-side from the request metadata. + MISSIONS/SETUP map to the owner contexts this strategy was built with. + USERS/ORGANIZATIONS (read-only cross-owner) and UNSPECIFIED hold no concrete + id here, so they return a kind-only marker (`user:`, `organization:`, + `unspecified:`); the storage service resolves the id — or applies its default + for UNSPECIFIED — server-side from the request metadata. Args: context: The context kind to resolve. Returns: The context string: `missions:`, `setup_versions:`, or the - kind marker `users:` / `organizations:` / `unspecified:`. + kind marker `user:` / `organization:` / `unspecified:`. """ match context: - case ContextStorage.MISSIONS: + case Context.MISSIONS: return self.mission_id - case ContextStorage.SETUP_VERSIONS: + case Context.SETUP: return self.setup_version_id case _: return f"{context.value}:" @@ -251,7 +252,7 @@ async def store( record_id: str | None, data: dict[str, Any], data_type: DataType = DataType.OUTPUT, - context: ContextStorage = ContextStorage.MISSIONS, + context: Context = Context.MISSIONS, visibility: Visibility = Visibility.UNSPECIFIED, ) -> StorageRecord: """Store a new record in the storage. @@ -282,9 +283,7 @@ async def store( async with self._record_lock(record.context, collection, record_id): return await self._store(record) - async def read( - self, collection: str, record_id: str, context: ContextStorage = ContextStorage.MISSIONS - ) -> StorageRecord | None: + async def read(self, collection: str, record_id: str, context: Context = Context.MISSIONS) -> StorageRecord | None: """Get a record by key under the given scope. Args: @@ -304,7 +303,7 @@ async def update( collection: str, record_id: str, data: dict[str, Any], - context: ContextStorage = ContextStorage.MISSIONS, + context: Context = Context.MISSIONS, visibility: Visibility = Visibility.UNSPECIFIED, ) -> StorageRecord | None: """Validate & overwrite an existing record under the given scope. @@ -324,7 +323,7 @@ async def update( async with self._record_lock(ctx, collection, record_id): return await self._update(collection, record_id, validated_data, ctx, visibility) - async def remove(self, collection: str, record_id: str, context: ContextStorage = ContextStorage.MISSIONS) -> bool: + async def remove(self, collection: str, record_id: str, context: Context = Context.MISSIONS) -> bool: """Delete a record from the storage under the given scope. Args: @@ -345,7 +344,7 @@ async def remove(self, collection: str, record_id: str, context: ContextStorage async def list( self, collection: str, - context: ContextStorage = ContextStorage.MISSIONS, + context: Context = Context.MISSIONS, visibilities: list[Visibility] | None = None, ) -> list[StorageRecord]: """Get all records in a collection under the given scope. @@ -361,7 +360,7 @@ async def list( """ return await self._list(collection, self._resolve_context(context), visibilities) - async def remove_collection(self, collection: str, context: ContextStorage = ContextStorage.MISSIONS) -> bool: + async def remove_collection(self, collection: str, context: Context = Context.MISSIONS) -> bool: """Wipe a collection clean under the given scope. Args: @@ -385,7 +384,7 @@ async def upsert( record_id: str, data: dict[str, Any], data_type: DataType = DataType.OUTPUT, - context: ContextStorage = ContextStorage.MISSIONS, + context: Context = Context.MISSIONS, visibility: Visibility = Visibility.UNSPECIFIED, ) -> StorageRecord: """Insert or update a record atomically under the given scope. diff --git a/tests/services/filesystem/test_grpc_filesystem.py b/tests/services/filesystem/test_grpc_filesystem.py index d10af57f..d779b7a9 100644 --- a/tests/services/filesystem/test_grpc_filesystem.py +++ b/tests/services/filesystem/test_grpc_filesystem.py @@ -5,6 +5,8 @@ import secrets import string import types +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock import grpc import grpc_testing @@ -16,9 +18,14 @@ ) from google.protobuf import struct_pb2 from grpc.framework.foundation import logging_pool +from hypothesis import given +from hypothesis import strategies as st +from mock_filesystem_servicer import MockFilesystemServicer +from tests.fixtures.grpc_fixtures import FakeContext +from digitalkin.grpc_servers.exceptions import PermissionDeniedError from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.models.services.filesystem import ContextFile +from digitalkin.models.services.services import Context from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.filesystem.exceptions import FilesystemServiceError from digitalkin.services.filesystem.filesystem_strategy import ( @@ -27,8 +34,9 @@ UploadFileData, ) from digitalkin.services.filesystem.grpc_filesystem import GrpcFilesystem -from mock_filesystem_servicer import MockFilesystemServicer -from tests.fixtures.grpc_fixtures import FakeContext + +if TYPE_CHECKING: + from digitalkin.models.services import services service_instance = MockFilesystemServicer() service_name = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] @@ -92,7 +100,7 @@ def client(test_channel: grpc_testing.Channel) -> GrpcFilesystem: # Override the channel and stub to use our test channel client.stub = filesystem_service_pb2_grpc.FilesystemServiceStub(test_channel) - async def _test_exec_grpc_query(self, query_endpoint, request): + async def _test_exec_grpc_query(self, query_endpoint, request) -> object: response = getattr(self.stub, query_endpoint)(request) return await response if asyncio.iscoroutine(response) else response @@ -1075,17 +1083,17 @@ class TestContextScopes: @pytest.mark.parametrize( ("context", "wire"), [ - (ContextFile.MISSIONS, filesystem_pb2.CONTEXT_MISSIONS), - (ContextFile.SETUP, filesystem_pb2.CONTEXT_SETUP), - (ContextFile.USERS, filesystem_pb2.CONTEXT_USERS), - (ContextFile.ORGANIZATIONS, filesystem_pb2.CONTEXT_ORGANIZATIONS), - (ContextFile.UNSPECIFIED, filesystem_pb2.CONTEXT_UNSPECIFIED), + (Context.MISSIONS, filesystem_pb2.CONTEXT_MISSIONS), + (Context.SETUP, filesystem_pb2.CONTEXT_SETUP), + (Context.USERS, filesystem_pb2.CONTEXT_USERS), + (Context.ORGANIZATIONS, filesystem_pb2.CONTEXT_ORGANIZATIONS), + (Context.UNSPECIFIED, filesystem_pb2.CONTEXT_UNSPECIFIED), ], ) def test_get_files_forwards_context_kind( self, - context: ContextFile, - wire: "filesystem_pb2.ContextFile", + context: Context, + wire: "services.Context", client: GrpcFilesystem, test_channel: grpc_testing.Channel, mock_servicer: MockFilesystemServicer, @@ -1121,7 +1129,7 @@ def test_get_file_forwards_cross_owner_context( """get_file under USERS emits CONTEXT_USERS on the wire.""" future = client_execution_thread_pool.submit( asyncio.run, - client.get_file("file_x", context=ContextFile.USERS), + client.get_file("file_x", context=Context.USERS), ) method_desc = service_name.methods_by_name["GetFile"] @@ -1132,3 +1140,52 @@ def test_get_file_forwards_cross_owner_context( rpc.send_initial_metadata(()) rpc.terminate(mock_servicer.GetFile(request, FakeContext()), (), grpc.StatusCode.OK, "") assert isinstance(future.result(timeout=5.0), FilesystemRecord) + + +class TestContextEnumContract: + """SDK ``Context`` kind -> filesystem wire enum (``_context_enum``).""" + + _WIRE = ( + (Context.MISSIONS, filesystem_pb2.CONTEXT_MISSIONS), + (Context.SETUP, filesystem_pb2.CONTEXT_SETUP), + (Context.USERS, filesystem_pb2.CONTEXT_USERS), + (Context.ORGANIZATIONS, filesystem_pb2.CONTEXT_ORGANIZATIONS), + (Context.UNSPECIFIED, filesystem_pb2.CONTEXT_UNSPECIFIED), + ) + + @pytest.mark.unit + @pytest.mark.contract + @pytest.mark.parametrize(("ctx", "wire"), _WIRE) + def test_context_enum_maps_to_wire(self, ctx: "Context", wire: int) -> None: + """Each Context kind maps to its filesystem proto ``CONTEXT_*`` constant.""" + assert GrpcFilesystem._context_enum(ctx) == wire + + @pytest.mark.property + @given(ctx=st.sampled_from(list(Context))) + def test_context_enum_is_total(self, ctx: "Context") -> None: + """Every Context kind maps to a defined filesystem wire enum (never crashes).""" + assert GrpcFilesystem._context_enum(ctx) in {wire for _, wire in self._WIRE} + + +class TestFilesystemRefusalAndFailures: + """Refusals (PERMISSION_DENIED) propagate; other gRPC failures wrap as FilesystemServiceError.""" + + @pytest.mark.grpc + @pytest.mark.regression + async def test_permission_denied_propagates(self, client: GrpcFilesystem) -> None: + """Authz refusals are re-raised as-is, never masked as a service error.""" + client.exec_grpc_query = AsyncMock(side_effect=PermissionDeniedError("denied")) # type: ignore[method-assign] + with pytest.raises(PermissionDeniedError): + await client.get_file("f") + with pytest.raises(PermissionDeniedError): + await client.get_files(FileFilter()) + + @pytest.mark.grpc + @pytest.mark.chaos + async def test_grpc_failure_wrapped(self, client: GrpcFilesystem) -> None: + """A generic gRPC failure surfaces as FilesystemServiceError on reads.""" + client.exec_grpc_query = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] + with pytest.raises(FilesystemServiceError): + await client.get_file("f") + with pytest.raises(FilesystemServiceError): + await client.get_files(FileFilter()) diff --git a/tests/services/storage/test_grpc_storage.py b/tests/services/storage/test_grpc_storage.py index 8f0b085c..e95da6f6 100644 --- a/tests/services/storage/test_grpc_storage.py +++ b/tests/services/storage/test_grpc_storage.py @@ -17,6 +17,9 @@ import grpc_testing import pytest from agentic_mesh_protocol.storage.v1 import data_pb2, storage_service_pb2, storage_service_pb2_grpc +from google.protobuf.struct_pb2 import Struct +from hypothesis import given +from hypothesis import strategies as st from pydantic import BaseModel, Field from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext from tests.services.storage.mock_storage_servicer import MockStorageServicer @@ -25,7 +28,8 @@ from digitalkin.grpc_servers.utils.circuit_breaker import CircuitBreaker from digitalkin.models.grpc_servers.circuit_breaker import CBState from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.models.services.storage import ContextStorage, DataType, Visibility +from digitalkin.models.services.services import Context +from digitalkin.models.services.storage import DataType, Visibility from digitalkin.models.settings.grpc_client import get_circuit_breaker_settings, get_grpc_client_settings from digitalkin.services.storage.exceptions import StorageServiceError from digitalkin.services.storage.grpc_storage import GrpcStorage @@ -71,7 +75,7 @@ class LogDataModel(BaseModel): def thread_pool(): """Create thread pool and ensure cleanup. - Returns: + Yields: ThreadPoolExecutor instance """ pool = futures.ThreadPoolExecutor(max_workers=1) @@ -1199,9 +1203,9 @@ def test_list_cross_owner_context_and_visibilities( ] for scope_context, wire in ( - (ContextStorage.USERS, data_pb2.CONTEXT_USERS), - (ContextStorage.ORGANIZATIONS, data_pb2.CONTEXT_ORGANIZATIONS), - (ContextStorage.UNSPECIFIED, data_pb2.CONTEXT_UNSPECIFIED), + (Context.USERS, data_pb2.CONTEXT_USERS), + (Context.ORGANIZATIONS, data_pb2.CONTEXT_ORGANIZATIONS), + (Context.UNSPECIFIED, data_pb2.CONTEXT_UNSPECIFIED), ): list_future = thread_pool.submit( asyncio.run, @@ -1562,7 +1566,10 @@ def _open_storage_breaker(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.grpc @pytest.mark.unit async def test_store_logs_quietly_when_circuit_open( - self, client: GrpcStorage, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, + self, + client: GrpcStorage, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: """Open-circuit StoreRecord raises but logs at DEBUG (no stack trace).""" self._open_storage_breaker(monkeypatch) @@ -1585,7 +1592,10 @@ async def test_store_logs_quietly_when_circuit_open( @pytest.mark.grpc @pytest.mark.unit async def test_read_logs_quietly_when_circuit_open( - self, client: GrpcStorage, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, + self, + client: GrpcStorage, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: """Open-circuit ReadRecord returns None and logs at DEBUG only.""" self._open_storage_breaker(monkeypatch) @@ -1665,3 +1675,183 @@ def test_unavailable_opens_breaker( assert result is None assert CircuitBreaker.get_or_create("StorageService").state == CBState.OPEN + + +# ============================================================================ +# Enum coverage: Visibility & Context (new SDK enums used by the storage service) +# ============================================================================ + + +class TestVisibilityEnumMapping: + """SDK ``Visibility`` <-> storage-proto wire enum, both directions.""" + + _WIRE = ( + (Visibility.UNSPECIFIED, data_pb2.VISIBILITY_UNSPECIFIED), + (Visibility.PUBLIC, data_pb2.VISIBILITY_PUBLIC), + (Visibility.PRIVATE, data_pb2.VISIBILITY_PRIVATE), + (Visibility.INTERNAL, data_pb2.VISIBILITY_INTERNAL), + ) + + @pytest.mark.unit + @pytest.mark.parametrize(("vis", "wire"), _WIRE) + def test_visibility_enum_maps_to_wire(self, vis: Visibility, wire: int) -> None: + """Each SDK visibility maps to its proto ``VISIBILITY_*`` constant.""" + assert GrpcStorage._visibility_enum(vis) == wire + + @pytest.mark.contract + def test_sdk_visibility_names_mirror_proto(self) -> None: + """Every SDK Visibility has a matching ``VISIBILITY_`` in the proto.""" + proto_names = {v.name for v in data_pb2.Visibility.DESCRIPTOR.values} + assert {f"VISIBILITY_{v.name}" for v in Visibility} <= proto_names + + @pytest.mark.contract + def test_visibility_values_are_lowercase_string_names(self) -> None: + """Visibility values are intentionally strings mirroring the member name.""" + for v in Visibility: + assert isinstance(v.value, str) + assert v.value == v.name.lower() + + @pytest.mark.regression + def test_visibility_enum_avoids_uncallable_proto_wrapper(self) -> None: + """``data_pb2.Visibility(...)`` is not callable at runtime; the mapper must not rely on it.""" + with pytest.raises(TypeError): + data_pb2.Visibility(1) + assert GrpcStorage._visibility_enum(Visibility.PUBLIC) == data_pb2.VISIBILITY_PUBLIC + + @pytest.mark.property + @given(vis=st.sampled_from(list(Visibility))) + def test_visibility_round_trips_through_wire(self, vis: Visibility) -> None: + """Write mapping -> proto -> read mapping recovers the same member.""" + wire = GrpcStorage._visibility_enum(vis) + name = data_pb2.Visibility.Name(wire).removeprefix("VISIBILITY_") + assert Visibility[name] is vis + + @pytest.mark.validation + def test_unknown_visibility_name_is_rejected(self) -> None: + """Name-based lookup (used by the tools) rejects unknown levels.""" + with pytest.raises(KeyError): + _ = Visibility["BOGUS"] + + +class TestVisibilityWire: + """Visibility on the wire: sent on store, reconstructed on read.""" + + @pytest.mark.grpc + @pytest.mark.smoke + @pytest.mark.parametrize( + "vis", [Visibility.PUBLIC, Visibility.PRIVATE, Visibility.INTERNAL, Visibility.UNSPECIFIED] + ) + def test_store_puts_visibility_on_request( + self, + vis: Visibility, + client: GrpcStorage, + test_channel: grpc_testing.Channel, + mock_servicer: MockStorageServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """A store carries the chosen visibility as the proto wire enum.""" + data = {"mission_id": MISSION_ID, "name": "vis", "value": 1} + method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] + + future = thread_pool.submit(asyncio.run, client.store("test_collection", "vis_rec", data, visibility=vis)) + _, request, rpc = test_channel.take_unary_unary(method_desc) + + assert request.visibility == GrpcStorage._visibility_enum(vis) + + rpc.send_initial_metadata(()) + rpc.terminate(mock_servicer.StoreRecord(request, FakeContext()), (), grpc.StatusCode.OK, "") + assert future.result(timeout=1.0) is not None + + @pytest.mark.unit + @pytest.mark.parametrize("vis", list(Visibility)) + def test_build_record_reads_visibility_from_wire(self, client: GrpcStorage, vis: Visibility) -> None: + """Reading a record reconstructs the SDK visibility from the proto int (string-valued enum).""" + struct = Struct() + struct.update({"mission_id": MISSION_ID, "name": "vis", "value": 1}) + proto = data_pb2.StorageRecord( + context=MISSION_ID, + collection="test_collection", + record_id="r", + data=struct, + data_type=data_pb2.OUTPUT, + visibility=GrpcStorage._visibility_enum(vis), + ) + assert client._build_record_from_proto(proto).visibility is vis + + @pytest.mark.edge_case + def test_unknown_wire_visibility_is_skipped(self, client: GrpcStorage) -> None: + """An out-of-range wire visibility makes the record skipped rather than crash a whole list.""" + struct = Struct() + struct.update({"mission_id": MISSION_ID, "name": "vis", "value": 1}) + proto = data_pb2.StorageRecord( + context=MISSION_ID, + collection="test_collection", + record_id="r", + data=struct, + data_type=data_pb2.OUTPUT, + visibility=99, + ) + assert client._build_record_or_skip(proto) is None + + +class TestContextWireMapping: + """SDK ``Context`` kind -> storage wire enum (via ``_resolve_context`` + ``_context_enum``).""" + + @pytest.mark.contract + @pytest.mark.parametrize( + ("ctx", "wire"), + [ + (Context.MISSIONS, data_pb2.CONTEXT_MISSIONS), + (Context.SETUP, data_pb2.CONTEXT_SETUP_VERSIONS), + (Context.USERS, data_pb2.CONTEXT_USERS), + (Context.ORGANIZATIONS, data_pb2.CONTEXT_ORGANIZATIONS), + (Context.UNSPECIFIED, data_pb2.CONTEXT_UNSPECIFIED), + ], + ) + def test_context_resolves_to_wire(self, client: GrpcStorage, ctx: Context, wire: int) -> None: + """Each Context kind resolves + maps to the expected wire enum.""" + assert client._context_enum(client._resolve_context(ctx)) == wire + + @pytest.mark.regression + def test_cross_owner_markers_are_singular(self, client: GrpcStorage) -> None: + """Unified Context values are singular; kind-only markers must match them.""" + assert client._resolve_context(Context.USERS) == "user:" + assert client._resolve_context(Context.ORGANIZATIONS) == "organization:" + assert client._resolve_context(Context.UNSPECIFIED) == "unspecified:" + + +class TestStorageRefusalAndFailures: + """Refusals (PERMISSION_DENIED) propagate; other gRPC failures degrade gracefully.""" + + @pytest.mark.grpc + @pytest.mark.regression + async def test_permission_denied_propagates_on_all_ops(self, client: GrpcStorage) -> None: + """Authz refusals are never swallowed — every op re-raises PermissionDeniedError.""" + client.exec_grpc_query = AsyncMock(side_effect=PermissionDeniedError("denied")) # type: ignore[method-assign] + data = {"mission_id": MISSION_ID, "name": "x", "value": 1} + with pytest.raises(PermissionDeniedError): + await client.store("test_collection", "r", data) + with pytest.raises(PermissionDeniedError): + await client.read("test_collection", "r") + with pytest.raises(PermissionDeniedError): + await client.update("test_collection", "r", data) + with pytest.raises(PermissionDeniedError): + await client.remove("test_collection", "r") + with pytest.raises(PermissionDeniedError): + await client.list("test_collection") + with pytest.raises(PermissionDeniedError): + await client.remove_collection("test_collection") + + @pytest.mark.grpc + @pytest.mark.chaos + async def test_grpc_failure_degrades_gracefully(self, client: GrpcStorage) -> None: + """A generic gRPC failure raises on writes-that-must-confirm and returns empty/false on best-effort reads.""" + client.exec_grpc_query = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] + data = {"mission_id": MISSION_ID, "name": "x", "value": 1} + with pytest.raises(StorageServiceError): + await client.store("test_collection", "r", data) + assert await client.read("test_collection", "r") is None + assert await client.update("test_collection", "r", data) is None + assert await client.remove("test_collection", "r") is False + assert await client.list("test_collection") == [] + assert await client.remove_collection("test_collection") is False