From 4961b055b95244ad3a9c98f314005a657f0fa06c Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 12:05:46 +0530 Subject: [PATCH 01/54] UN-4232 [FEAT] Agent-KV API serving the table extractor only Tasks 2 and 3 of docs/superpowers/plans/2026-10-06-table-extractor-api-carveout.md. Stands the Agent-KV API up on a branch cut from main, routing `table` and nothing else, so table extraction reaches customers without waiting for the KV engine, the schema codegen path or the hardened sandbox to stabilise. WHAT CAME ACROSS The `agent_kv` Django app and its URL/settings mounts, the AGENT_KV storage type, the `agent_kv_callback` queue and its ide_callback tasks, the scheduler's sweep/TTL beat tasks, the internal API client, the webhook notifier, and the compose/worker wiring for all of it. FOUR SUBTRACTIONS, EACH REVERTIBLE IN ONE LINE 1. `kv` is out of `EXTRACTOR_ROUTES`. `SUPPORTED_EXTRACTORS` is derived from that table, so a `kv` submit now returns 400 at the serializer. This is the load-bearing line: leave `kv` routable with no `agentic_kv` plugin deployed and the submit returns 202, dispatches to `celery_executor_agentic_kv`, and sits in DISPATCHED forever -- no error at the producer, nothing in any log. `V1_EXTRACTOR_NAME`, `STAGE_NAMES` and `KVOptionsSerializer` all stay in the tree, dormant and still tested. 2. `celery_executor_agentic_kv` is out of both compose fleets and run-worker.sh, for the same reason. 3. `/validate` is unregistered. It compiles `kv` key schemas and nothing else; publishing it for an extractor this deployment refuses is an incoherent contract. The view class and `unstract/agent-kv-schema` stay. 4. No sandbox: not the worker, not its compose service, not PG_ROLE_SANDBOX, not its SANDBOX_* env block, not its registry/enum entries, not its coverage target. `agentic_table` keeps running generated code in the executor pod exactly as the IDE table path does in production today; UN-4215 is the fast-follow. worker-unified.Dockerfile is reverted to main: its UID pin exists so the sandbox pod can assert runAsNonRoot with a numeric UID, and its comments cite a chart template this PR does not ship. TESTS Two guards were INVERTED rather than deleted, which is the point of them: * `test_queue_consumer_wiring.py` used to assert the KV queue HAS a consumer; it now asserts no fleet advertises it. Mutation-checked -- re-adding the queue to compose fails the guard. * `test_registry.py` was entirely sandbox assertions; it now asserts the ide_callback worker subscribes to `agent_kv_callback` and that both terminal callbacks route there. A job whose callback is unconsumed runs to SUCCESS and then sits in RUNNING forever, result unpersisted and slot unreleased. Backend suite re-pointed from `kv` to `table`. `KVOptionsSerializer` is now tested directly rather than through a submit -- `kv` is refused at `validate_name` before any options validator runs, so driving those rules through `SubmitSerializer` would have asserted nothing while still passing. E2E: the generic scenarios (auth, cancel, concurrency slot release, delete, page cap, sync-wait, webhook, 429, unreadable PDF, Excel) re-pointed at the table extractor; the five kv-engine scenarios dropped (happy path -- the table twin already covers it, /validate, calculations, hostile calculations, and the KV document cache, which the table path does not have until spec step 5). Green: 191 backend agent_kv, 65 workers, 42 agent-kv-schema, 1 filesystem. ruff 0.3.4 (the pinned pre-commit version) clean on check and format. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/__init__.py | 0 backend/agent_kv/apps.py | 6 + backend/agent_kv/constants.py | 65 + backend/agent_kv/dispatch.py | 158 + backend/agent_kv/exceptions.py | 16 + backend/agent_kv/execution_serializers.py | 323 ++ backend/agent_kv/execution_urls.py | 29 + backend/agent_kv/execution_views.py | 373 ++ backend/agent_kv/execution_views_result.py | 63 + backend/agent_kv/internal_urls.py | 31 + backend/agent_kv/internal_views.py | 267 + backend/agent_kv/key_validator.py | 45 + backend/agent_kv/maintenance.py | 249 + backend/agent_kv/management/__init__.py | 0 .../agent_kv/management/commands/__init__.py | 0 .../management/commands/agent_kv_sweep.py | 24 + .../commands/agent_kv_ttl_cleanup.py | 24 + backend/agent_kv/migrations/0001_initial.py | 157 + .../migrations/0002_agentkvjob_extractor.py | 21 + .../0003_agentkvjob_cleanup_failed_at.py | 41 + backend/agent_kv/migrations/__init__.py | 0 backend/agent_kv/models.py | 142 + backend/agent_kv/permissions.py | 8 + backend/agent_kv/rate_limiter.py | 109 + backend/agent_kv/serializers.py | 15 + backend/agent_kv/storage.py | 165 + backend/agent_kv/tests/__init__.py | 0 backend/agent_kv/tests/_factories.py | 26 + backend/agent_kv/tests/fixtures/two_page.pdf | Bin 0 -> 551 bytes backend/agent_kv/tests/test_auth.py | 130 + backend/agent_kv/tests/test_dispatch.py | 297 ++ backend/agent_kv/tests/test_internal_views.py | 686 +++ backend/agent_kv/tests/test_job_views.py | 513 ++ backend/agent_kv/tests/test_key_management.py | 95 + .../tests/test_maintenance_commands.py | 54 + backend/agent_kv/tests/test_models.py | 54 + backend/agent_kv/tests/test_rate_limiter.py | 155 + backend/agent_kv/tests/test_storage.py | 304 ++ .../agent_kv/tests/test_submit_serializer.py | 365 ++ backend/agent_kv/tests/test_submit_view.py | 733 +++ backend/agent_kv/tests/test_sweeps.py | 646 +++ .../tests/test_table_extractor_routing.py | 241 + backend/agent_kv/tests/test_validate_view.py | 130 + backend/agent_kv/urls.py | 23 + backend/agent_kv/views.py | 55 + backend/backend/base_urls.py | 2 + backend/backend/internal_base_urls.py | 6 + backend/backend/settings/base.py | 57 +- backend/backend/urls_v2.py | 1 + backend/pyproject.toml | 2 + backend/sample.env | 27 + backend/uv.lock | 7 + docker/docker-compose.yaml | 9 +- docker/sample.env | 49 + docs/agent-kv-api.md | 1011 ++++ ...2026-10-06-table-extractor-api-carveout.md | 358 ++ pyproject.toml | 1 + tests/compose/docker-compose.test.yaml | 47 + tests/e2e/agent_kv/__init__.py | 0 tests/e2e/agent_kv/conftest.py | 283 + tests/e2e/agent_kv/fixtures/invoice.pdf | Bin 0 -> 2435 bytes tests/e2e/agent_kv/fixtures/invoice.xlsx | Bin 0 -> 5105 bytes tests/e2e/agent_kv/fixtures/rent_roll.pdf | Bin 0 -> 2492 bytes tests/e2e/agent_kv/test_agent_kv_e2e.py | 709 +++ tests/groups.yaml | 45 +- tox.ini | 5 + unstract/agent-kv-schema/pyproject.toml | 13 + .../src/unstract/agent_kv_schema/__init__.py | 15 + .../src/unstract/agent_kv_schema/compile.py | 265 + .../unstract/agent_kv_schema/constraints.py | 258 + .../unstract/agent_kv_schema/dataclasses.py | 53 + .../src/unstract/agent_kv_schema/kv_schema.py | 224 + .../unstract/agent_kv_schema/validators.py | 115 + unstract/agent-kv-schema/tests/__init__.py | 0 .../agent-kv-schema/tests/test_compile.py | 250 + .../agent-kv-schema/tests/test_constraints.py | 0 .../tests/test_constraints_tolerance.py | 131 + unstract/agent-kv-schema/uv.lock | 8 + .../filesystem/file_storage_config.py | 2 + .../unstract/filesystem/file_storage_types.py | 1 + unstract/filesystem/tests/__init__.py | 0 .../tests/test_agent_kv_storage_type.py | 15 + uv.lock | 7 + workers/ide_callback/agent_kv_tasks.py | 181 + workers/ide_callback/tasks.py | 16 + workers/ide_callback/tests/__init__.py | 0 .../tests/test_agent_kv_callbacks.py | 401 ++ workers/ide_callback/worker.py | 13 + workers/pyproject.toml | 2 + workers/run-worker-docker.sh | 5 +- workers/run-worker.sh | 7 +- workers/sample.env | 18 + workers/scheduler/agent_kv_tasks.py | 71 + workers/scheduler/tasks.py | 27 +- workers/shared/api/internal_client.py | 109 + workers/shared/enums/worker_enums_base.py | 4 + .../shared/infrastructure/config/registry.py | 5 + .../infrastructure/config/tests/__init__.py | 0 .../config/tests/test_registry.py | 44 + workers/shared/tests/test_agent_kv_client.py | 90 + workers/shared/tests/test_webhook_notify.py | 221 + workers/shared/utils/webhook_notify.py | 85 + .../tests/test_agent_kv_scheduler_tasks.py | 95 + workers/tests/test_queue_consumer_wiring.py | 190 + .../tests/test_worker_task_module_imports.py | 68 +- workers/uv.lock | 4677 +++++++++-------- 106 files changed, 14777 insertions(+), 2366 deletions(-) create mode 100644 backend/agent_kv/__init__.py create mode 100644 backend/agent_kv/apps.py create mode 100644 backend/agent_kv/constants.py create mode 100644 backend/agent_kv/dispatch.py create mode 100644 backend/agent_kv/exceptions.py create mode 100644 backend/agent_kv/execution_serializers.py create mode 100644 backend/agent_kv/execution_urls.py create mode 100644 backend/agent_kv/execution_views.py create mode 100644 backend/agent_kv/execution_views_result.py create mode 100644 backend/agent_kv/internal_urls.py create mode 100644 backend/agent_kv/internal_views.py create mode 100644 backend/agent_kv/key_validator.py create mode 100644 backend/agent_kv/maintenance.py create mode 100644 backend/agent_kv/management/__init__.py create mode 100644 backend/agent_kv/management/commands/__init__.py create mode 100644 backend/agent_kv/management/commands/agent_kv_sweep.py create mode 100644 backend/agent_kv/management/commands/agent_kv_ttl_cleanup.py create mode 100644 backend/agent_kv/migrations/0001_initial.py create mode 100644 backend/agent_kv/migrations/0002_agentkvjob_extractor.py create mode 100644 backend/agent_kv/migrations/0003_agentkvjob_cleanup_failed_at.py create mode 100644 backend/agent_kv/migrations/__init__.py create mode 100644 backend/agent_kv/models.py create mode 100644 backend/agent_kv/permissions.py create mode 100644 backend/agent_kv/rate_limiter.py create mode 100644 backend/agent_kv/serializers.py create mode 100644 backend/agent_kv/storage.py create mode 100644 backend/agent_kv/tests/__init__.py create mode 100644 backend/agent_kv/tests/_factories.py create mode 100644 backend/agent_kv/tests/fixtures/two_page.pdf create mode 100644 backend/agent_kv/tests/test_auth.py create mode 100644 backend/agent_kv/tests/test_dispatch.py create mode 100644 backend/agent_kv/tests/test_internal_views.py create mode 100644 backend/agent_kv/tests/test_job_views.py create mode 100644 backend/agent_kv/tests/test_key_management.py create mode 100644 backend/agent_kv/tests/test_maintenance_commands.py create mode 100644 backend/agent_kv/tests/test_models.py create mode 100644 backend/agent_kv/tests/test_rate_limiter.py create mode 100644 backend/agent_kv/tests/test_storage.py create mode 100644 backend/agent_kv/tests/test_submit_serializer.py create mode 100644 backend/agent_kv/tests/test_submit_view.py create mode 100644 backend/agent_kv/tests/test_sweeps.py create mode 100644 backend/agent_kv/tests/test_table_extractor_routing.py create mode 100644 backend/agent_kv/tests/test_validate_view.py create mode 100644 backend/agent_kv/urls.py create mode 100644 backend/agent_kv/views.py create mode 100644 docs/agent-kv-api.md create mode 100644 docs/superpowers/plans/2026-10-06-table-extractor-api-carveout.md create mode 100644 tests/e2e/agent_kv/__init__.py create mode 100644 tests/e2e/agent_kv/conftest.py create mode 100644 tests/e2e/agent_kv/fixtures/invoice.pdf create mode 100644 tests/e2e/agent_kv/fixtures/invoice.xlsx create mode 100644 tests/e2e/agent_kv/fixtures/rent_roll.pdf create mode 100644 tests/e2e/agent_kv/test_agent_kv_e2e.py create mode 100644 unstract/agent-kv-schema/pyproject.toml create mode 100644 unstract/agent-kv-schema/src/unstract/agent_kv_schema/__init__.py create mode 100644 unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py create mode 100644 unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py create mode 100644 unstract/agent-kv-schema/src/unstract/agent_kv_schema/dataclasses.py create mode 100644 unstract/agent-kv-schema/src/unstract/agent_kv_schema/kv_schema.py create mode 100644 unstract/agent-kv-schema/src/unstract/agent_kv_schema/validators.py create mode 100644 unstract/agent-kv-schema/tests/__init__.py create mode 100644 unstract/agent-kv-schema/tests/test_compile.py create mode 100644 unstract/agent-kv-schema/tests/test_constraints.py create mode 100644 unstract/agent-kv-schema/tests/test_constraints_tolerance.py create mode 100644 unstract/agent-kv-schema/uv.lock create mode 100644 unstract/filesystem/tests/__init__.py create mode 100644 unstract/filesystem/tests/test_agent_kv_storage_type.py create mode 100644 workers/ide_callback/agent_kv_tasks.py create mode 100644 workers/ide_callback/tests/__init__.py create mode 100644 workers/ide_callback/tests/test_agent_kv_callbacks.py create mode 100644 workers/scheduler/agent_kv_tasks.py create mode 100644 workers/shared/infrastructure/config/tests/__init__.py create mode 100644 workers/shared/infrastructure/config/tests/test_registry.py create mode 100644 workers/shared/tests/test_agent_kv_client.py create mode 100644 workers/shared/tests/test_webhook_notify.py create mode 100644 workers/shared/utils/webhook_notify.py create mode 100644 workers/tests/test_agent_kv_scheduler_tasks.py create mode 100644 workers/tests/test_queue_consumer_wiring.py diff --git a/backend/agent_kv/__init__.py b/backend/agent_kv/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/agent_kv/apps.py b/backend/agent_kv/apps.py new file mode 100644 index 0000000000..0b70b303e1 --- /dev/null +++ b/backend/agent_kv/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AgentKvConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "agent_kv" diff --git a/backend/agent_kv/constants.py b/backend/agent_kv/constants.py new file mode 100644 index 0000000000..431316e078 --- /dev/null +++ b/backend/agent_kv/constants.py @@ -0,0 +1,65 @@ +#: v1 accepts exactly one extractor (`kv`), so the job row does not carry which +#: one it ran. When fan-out lands this becomes per-job state rather than a +#: constant -- the wire format (spec §7.0) is already shaped for that. +V1_EXTRACTOR_NAME = "kv" + +STAGE_NAMES = [ + "document_processing", + "extraction", + "qa", + "challenge", + "normalize", + "constraints", + "codegen", + "code_execution", +] +EXECUTOR_NAME = "agentic_kv" +OPERATION_KV_EXTRACT = "kv_extract" +EXECUTION_SOURCE = "agent_kv_api" + +#: The table extractor, served by the cloud `agentic_table` plugin's blind-API +#: operation. Same executor as the IDE table path (and therefore the same, +#: already-wired `celery_executor_agentic_table` queue) with a second operation +#: -- a new executor name would derive a new queue needing wiring at five +#: sites, and an unwired queue accepts work and drains nothing, silently. +TABLE_EXTRACTOR_NAME = "table" + +TABLE_EXECUTOR_NAME = "agentic_table" +OPERATION_TABLE_EXTRACT_API = "table_extract_api" + +#: Which executor and operation each extractor dispatches to. `dispatch_job` +#: reads this rather than hardcoding one pair, so adding an extractor is one +#: entry here plus its options serializer and stage list. +#: +#: **`kv` is deliberately absent.** This deployment ships the `agentic_table` +#: plugin and not `agentic_kv`, so nothing drains `celery_executor_agentic_kv`. +#: `SUPPORTED_EXTRACTORS` is derived from this table's keys +#: (`execution_serializers.py`), so the omission turns a `kv` submit into a 400 +#: at the serializer. Restoring the entry below is all it takes to re-enable +#: the extractor once the plugin ships -- and restoring it WITHOUT the plugin +#: is the failure this guards: the submit would be accepted with a 202, +#: dispatched to a queue with no consumer, and sit in DISPATCHED forever with +#: no error at the producer and nothing in any log to find. +#: +#: `V1_EXTRACTOR_NAME`, `STAGE_NAMES` and the KV options serializer stay in the +#: tree, dormant and still under test, so re-enabling is one line rather than a +#: content merge against the branch that carries the engine. +EXTRACTOR_ROUTES = { + TABLE_EXTRACTOR_NAME: (TABLE_EXECUTOR_NAME, OPERATION_TABLE_EXTRACT_API), +} + +#: The table engine reports one coarse stage: it has no node-level progress +#: hooks (the IDE path gets `stream_log` only), so inventing finer stages here +#: would describe progress the executor cannot actually report. +TABLE_STAGE_NAMES = ["table_extraction"] + +#: Stage names ARE wire format -- they are returned to clients -- and they are +#: extractor-specific (`qa`/`challenge`/`codegen` mean nothing to the table +#: extractor). `_status_document` filters a job's recorded stages through the +#: list for the extractor that ran: `StageReportView` persists whatever name +#: the executor sends, so without a per-extractor list a table job's stages +#: would be stored and then silently filtered out of every status response. +STAGE_NAMES_BY_EXTRACTOR = { + V1_EXTRACTOR_NAME: STAGE_NAMES, + TABLE_EXTRACTOR_NAME: TABLE_STAGE_NAMES, +} diff --git a/backend/agent_kv/dispatch.py b/backend/agent_kv/dispatch.py new file mode 100644 index 0000000000..54166bc7ad --- /dev/null +++ b/backend/agent_kv/dispatch.py @@ -0,0 +1,158 @@ +"""Executor dispatch glue (spec §5.3). One dispatch per job; UUID task_id.""" + +import logging +import uuid + +from celery import signature +from django.conf import settings +from django.utils import timezone + +from agent_kv.constants import EXECUTION_SOURCE, EXTRACTOR_ROUTES +from agent_kv.models import AgentKVJob, JobStatus +from unstract.sdk1.execution.context import ExecutionContext + +logger = logging.getLogger(__name__) + +CALLBACK_QUEUE = "agent_kv_callback" + + +class DispatchError(Exception): + """Enqueue failed; the caller terminalizes the job (spec §5.3).""" + + +def _dispatcher(): + # No `celery_app`: UN-4046 removed that parameter when the routing + # dispatcher's Celery branch went with the pg_queue_enabled flag. Passing it + # raises TypeError, so every submit failed to dispatch -- and because it + # fails at the call rather than at import, nothing catches it until a real + # request is made. + from pg_queue.executor_rpc import get_executor_dispatcher + + return get_executor_dispatcher() + + +def _platform_api_key(job) -> str: + # Lazy import: avoids Django app registry init order (mirrors + # PromptStudioHelper._get_platform_api_key). + from platform_settings_v2.platform_auth_service import ( + PlatformAuthenticationService, + ) + + # ``get_active_platform_key`` takes the org's public *slug* + # (``Organization.organization_id``, e.g. ``org_abc123``) and resolves it + # via ``get_organization_by_org_id`` -- NOT the row's UUID primary key that + # ``job.organization_id`` holds. Passing the PK here silently resolves to + # no organization and every dispatch fails with ``ActiveKeyNotFound`` + # (caught live in the Task 13b integration run). + org_slug = job.organization.organization_id + platform_key = PlatformAuthenticationService.get_active_platform_key(org_slug) + if not platform_key: + raise DispatchError(f"No active platform key for org {org_slug}") + return str(platform_key.key) + + +def dispatch_job(job, *, extractor: str, schema: dict, options: dict) -> None: + executor_name, operation = EXTRACTOR_ROUTES[extractor] + org_id = str(job.organization_id) + # Everything that can fail — platform-key lookup, context construction, + # and the enqueue call itself — lives inside this try so no internal + # failure (e.g. a transient DB error resolving the platform key) can + # escape as a raw, uncaught exception. Only the post-success bookkeeping + # below runs outside it. + try: + job.task_id = uuid.uuid4() + context = ExecutionContext( + executor_name=executor_name, + operation=operation, + run_id=str(job.id), + execution_source=EXECUTION_SOURCE, + organization_id=org_id, + executor_params={ + "job_id": str(job.id), + "input_ref": job.input_ref, + "schema": schema, + "options": options, + "platform_api_key": _platform_api_key(job), + # The CAP the engine must enforce (spec §6.1/§6.6), not the + # measured count -- job.pages_total is None for Excel (no + # pre-OCR page concept), which would otherwise leave the + # engine with nothing to check the post-OCR virtual-page cap + # against. The measured count still rides along separately. + "max_pages": settings.AGENT_KV_MAX_PAGES, + "pages_total": job.pages_total, + }, + ) + cb_kwargs = {"callback_kwargs": {"job_id": str(job.id), "org_id": org_id}} + _dispatcher().dispatch_with_callback( + context, + on_success=signature( + "agent_kv_complete", kwargs=cb_kwargs, queue=CALLBACK_QUEUE + ), + on_error=signature("agent_kv_error", kwargs=cb_kwargs, queue=CALLBACK_QUEUE), + task_id=str(job.task_id), + ) + except DispatchError: + raise + except Exception as e: + raise DispatchError(str(e)) from e + job.status = JobStatus.DISPATCHED + job.dispatched_at = timezone.now() + # Guarded queryset UPDATE, not job.save(): a plain save would blindly + # overwrite whatever status this job already raced to. Concretely: the + # executor can fail (or the job be cancelled) essentially instantly + # after enqueue, and its finalize callback can land -- marking the row + # FAILED/CANCELLED -- before this post-enqueue bookkeeping runs. An + # unconditional save() here would rewrite that terminal status back to + # DISPATCHED, un-terminalizing the job forever (nothing else ever + # revisits a DISPATCHED row). Only a still-PENDING row is advanced; a + # row this UPDATE doesn't match is left exactly as the winning writer + # left it. `modified_at` is stamped automatically by + # BaseModelQuerySet.update() (utils/models/base_model.py). + # Everything below is POST-ENQUEUE bookkeeping. The task is already on the + # queue, so a failure here must never be reported as a failed dispatch: + # `SubmitView` turns a DispatchError into a FAILED job, and the executor + # would then run, callback, and find a terminal row it cannot write to -- + # the caller told nothing was billed for work that did run. Wrapped rather + # than left to propagate, which is what the single pre-review UPDATE did. + # + # Losing the bookkeeping entirely is recoverable: sweep phase 1 reaps a + # still-PENDING row with no `dispatched_at`, and phase 2's + # `dispatched_at IS NULL` arm covers the non-PENDING case. + try: + _record_dispatch(job) + except Exception: + logger.exception( + "agent-kv: dispatch bookkeeping failed for job %s after enqueue " + "(task is queued; the sweep will reconcile)", + job.id, + ) + + +def _record_dispatch(job) -> None: + """Persist task_id/status/dispatched_at against whatever the row raced to.""" + advanced = AgentKVJob.objects.filter(id=job.id, status=JobStatus.PENDING).update( + task_id=job.task_id, + status=job.status, + dispatched_at=job.dispatched_at, + ) + if not advanced: + # The row moved off PENDING between the enqueue above and this write. + # The benign case is a terminal status (the guard's whole purpose) -- + # but there is a non-terminal one: StageReportView promotes + # PENDING -> RUNNING on the executor's FIRST stage report, which can + # easily land before this bookkeeping. The guard above then matches 0 + # rows and `dispatched_at` stays NULL -- and a non-terminal row with a + # NULL `dispatched_at` is invisible to BOTH sweep phases: phase 1 + # requires `status=PENDING`, phase 2 filters `dispatched_at__lt=cutoff` + # and SQL `NULL < x` is never true. The job reports `running` forever + # and `GET result` 409s for the life of the row, with nothing able to + # recover it. + # + # So stamp the dispatch bookkeeping for any still-non-terminal row, + # WITHOUT touching `status`: the row genuinely was dispatched, and + # moving RUNNING back to DISPATCHED would lose the executor's own + # progress. `dispatched_at__isnull=True` keeps this idempotent and + # stops a retry overwriting the original dispatch time. + AgentKVJob.objects.filter(id=job.id, dispatched_at__isnull=True).exclude( + status__in=list(AgentKVJob.TERMINAL) + ).update(task_id=job.task_id, dispatched_at=job.dispatched_at) diff --git a/backend/agent_kv/exceptions.py b/backend/agent_kv/exceptions.py new file mode 100644 index 0000000000..459821d2ef --- /dev/null +++ b/backend/agent_kv/exceptions.py @@ -0,0 +1,16 @@ +from rest_framework.exceptions import APIException + + +class EngineUnavailable(APIException): + status_code = 501 + default_detail = "agent-kv engine not available on this deployment" + + +class RateLimited(APIException): + status_code = 429 + default_detail = "Too many requests" + + +class JobNotFound(APIException): + status_code = 404 + default_detail = "Job not found" diff --git a/backend/agent_kv/execution_serializers.py b/backend/agent_kv/execution_serializers.py new file mode 100644 index 0000000000..38e845b497 --- /dev/null +++ b/backend/agent_kv/execution_serializers.py @@ -0,0 +1,323 @@ +"""Submit-time validation: every §6.1 cap lives here, before any paid work.""" + +import json + +import pdfplumber +from django.conf import settings +from rest_framework import serializers + +from agent_kv.constants import EXTRACTOR_ROUTES, TABLE_EXTRACTOR_NAME, V1_EXTRACTOR_NAME +from unstract.agent_kv_schema import SchemaError, compile_schema + +# Images are deliberately ABSENT, and that is a cross-repo contract, not an +# oversight. The cloud engine's `_build_agent_graph` (agentic_kv +# kv_extractor.py) treats only `.pdf/.xlsx/.xls` as a document; anything else +# takes a branch that skips `document_processor` entirely, and +# `ImageLoader.load_pages` -- the only thing that would populate pages for an +# image -- has no call site anywhere in the plugin (the engine's own comment +# there says images are "out of P2 scope"). +# +# Accepting them here anyway failed OPEN: an image dispatched normally with +# `pages_total=1`, every key came back not-found, and the job returned +# `success: true` with a page billed. Refusing at submit is the honest +# behaviour until the engine side is wired; re-add them in the same change that +# gives `load_pages` a call site, not before. +ALLOWED_EXTENSIONS = {".pdf", ".xlsx", ".xls"} +PDF_LIKE = {".pdf"} +EXTRACTION_MODES = ("whole-doc", "per-page") + + +# Derived, not repeated: the result and status documents key their payloads by +# these names, so a literal here that no route/stage list knows about would let +# the serializer accept a name the responses file under something else. +SUPPORTED_EXTRACTORS = tuple(EXTRACTOR_ROUTES) + + +class KVOptionsSerializer(serializers.Serializer): + """The `kv` extractor's own knobs (spec §7.1). + + These used to be top-level submit fields. They are extractor-scoped now + because they are meaningless to any other extractor -- `qa` and `challenge` + describe the KV agent pipeline, not "the request". + """ + + qa = serializers.BooleanField(required=False, default=True) + challenge = serializers.BooleanField(required=False, default=True) + extraction_mode = serializers.ChoiceField( + required=False, choices=EXTRACTION_MODES, default="whole-doc" + ) + structured_output = serializers.BooleanField(required=False, default=False) + calculations = serializers.CharField(required=False, allow_blank=True, default="") + document_class = serializers.CharField( + required=False, allow_blank=True, default="", max_length=256 + ) + key_notes = serializers.CharField( + required=False, allow_blank=True, default="", max_length=10_000 + ) + + def validate_calculations(self, v): + if v and not settings.AGENT_KV_CALCULATIONS_ENABLED: + raise serializers.ValidationError( + "calculations is not available on this deployment yet" + ) + if len(v.encode("utf-8")) > settings.AGENT_KV_MAX_CALCULATIONS_BYTES: + raise serializers.ValidationError( + f"calculations exceeds {settings.AGENT_KV_MAX_CALCULATIONS_BYTES} bytes" + ) + return v + + def validate_structured_output(self, v): + if v and not settings.AGENT_KV_STRUCTURED_OUTPUT_ENABLED: + raise serializers.ValidationError( + "structured_output is not available on this deployment yet" + ) + return v + + def validate(self, data): + # DRF silently DROPS unknown fields. For per-extractor options that is + # the wrong default: an option aimed at the wrong extractor (or a typo) + # would be discarded and the job would run with a silently different + # configuration than the caller asked for. Reject instead. + unknown = set(self.initial_data) - set(self.fields) + if unknown: + raise serializers.ValidationError( + f"unknown options for extractor 'kv': {sorted(unknown)}" + ) + return data + + +class TableOptionsSerializer(serializers.Serializer): + """The `table` extractor's own knobs (spec §7.1). + + Deliberately a subset of what the IDE path accepts: `output_path` and the + IDE callback hints (`prompt_key`, `doc_name`) are meaningless on the blind + API, and `enable_highlight` has no consumer there. + + **`enable_header_mapping` changes the result shape.** With it off, the + engine returns `output.tables` as a flat list of row dicts. With it on, the + engine wraps them as `{"header_mapping": ..., "rows": [...]}` + (`runner.py:1712`; the executor's own enrichment path unwraps the same + shape at `executor.py:577`). The API returns whichever the caller asked + for, unchanged -- so a client that sets this flag must read `.rows`. + """ + + instructions = serializers.CharField( + required=False, allow_blank=True, default="", max_length=10_000 + ) + json_structure = serializers.CharField( + required=False, allow_blank=True, default="", max_length=100_000 + ) + enable_header_mapping = serializers.BooleanField(required=False, default=False) + correct_number_separators = serializers.BooleanField(required=False, default=False) + number_format = serializers.ChoiceField( + required=False, choices=("US", "EU"), default="US" + ) + + def validate(self, data): + # Same reason KVOptionsSerializer rejects unknowns: DRF drops them + # silently, so an option aimed at the wrong extractor would be + # discarded and the job would run with a configuration the caller + # never asked for. + unknown = set(self.initial_data) - set(self.fields) + if unknown: + raise serializers.ValidationError( + f"unknown options for extractor '{TABLE_EXTRACTOR_NAME}': " + f"{sorted(unknown)}" + ) + return data + + +class TableKeysSerializer(serializers.Serializer): + """The `table` extractor's `keys`. + + The wire format gives every extractor a `keys` member; for the table + extractor the thing being asked for is a table, named by `target_table` + (the engine's one required extraction parameter). + """ + + target_table = serializers.CharField(max_length=256) + + def validate(self, data): + unknown = set(self.initial_data) - set(self.fields) + if unknown: + raise serializers.ValidationError( + f"unknown keys for extractor '{TABLE_EXTRACTOR_NAME}': {sorted(unknown)}" + ) + return data + + +# Each extractor's own validators. Keyed by the same names as EXTRACTOR_ROUTES; +# `test_every_supported_extractor_has_a_route_stage_list_and_options_serializer` +# keeps the three tables in step. +_OPTIONS_SERIALIZERS = { + V1_EXTRACTOR_NAME: KVOptionsSerializer, + TABLE_EXTRACTOR_NAME: TableOptionsSerializer, +} + + +class ExtractorSerializer(serializers.Serializer): + """One entry of the submit's `extractors` array (spec §7.0/§7.1).""" + + name = serializers.CharField() + keys = serializers.JSONField() + options = serializers.DictField(required=False, default=dict) + + compiled = None + + def validate_name(self, v): + if v not in SUPPORTED_EXTRACTORS: + raise serializers.ValidationError( + f"unknown extractor '{v}'; supported: {list(SUPPORTED_EXTRACTORS)}" + ) + return v + + def validate_keys(self, spec): + name = (self.initial_data or {}).get("name") + if name == TABLE_EXTRACTOR_NAME: + keys = TableKeysSerializer(data=spec if isinstance(spec, dict) else {}) + keys.is_valid(raise_exception=True) + return keys.validated_data + # kv: size is capped on the SERIALIZED form -- the cap exists to bound + # parse and compile cost, and `keys` arrives here already parsed out of + # the `extractors` JSON. + # ensure_ascii=False so this measures the SAME bytes the outer + # `extractors` cap measured. + serialized = json.dumps(spec, ensure_ascii=False).encode("utf-8") + if len(serialized) > settings.AGENT_KV_MAX_SCHEMA_BYTES: + raise serializers.ValidationError("keys schema too large") + try: + self.compiled = compile_schema(spec) + except SchemaError as e: + raise serializers.ValidationError(str(e)) + return spec + + def validate(self, data): + # Same reason the options block rejects unknowns, one level up: DRF drops + # unrecognised keys, so `"option"` or `"Options"` for `"options"` would + # be discarded whole, `options` would default to {}, and the job would + # run with qa=True/challenge=True -- roughly double the LLM spend the + # caller asked for, with a 202 and no indication anything was ignored. + unknown = set(self.initial_data) - set(self.fields) + if unknown: + raise serializers.ValidationError( + f"unknown keys on extractor entry: {sorted(unknown)}" + ) + opts_cls = _OPTIONS_SERIALIZERS[data["name"]] + opts = opts_cls(data=data.get("options") or {}) + opts.is_valid(raise_exception=True) + data["options"] = opts.validated_data + return data + + +class SubmitSerializer(serializers.Serializer): + """Submit-time validation: every §6.1 cap lives here, before any paid work. + + The wire format is extractor-scoped (§7.0): per-extractor schema and knobs + live inside `extractors`, and only fields describing the REQUEST stay top + level. There is deliberately no alias for the old flat shape. + """ + + file = serializers.FileField() + extractors = serializers.CharField() # JSON array string, or a file part + # Job-level: the page range drives the shared OCR pass and the §6.1 page + # cap, so it cannot differ between extractors reading the same document. + page_start = serializers.IntegerField(required=False, default=1, min_value=1) + page_end = serializers.IntegerField( + required=False, default=None, allow_null=True, min_value=1 + ) + timeout = serializers.IntegerField(required=False, default=0, min_value=0) + tags = serializers.ListField( + child=serializers.CharField(max_length=64), + required=False, + default=list, + max_length=20, + ) + custom_data = serializers.JSONField(required=False, default=None, allow_null=True) + webhook_url = serializers.URLField( + required=False, allow_blank=True, default="", max_length=1024 + ) + + #: ``{extractor_name: compiled schema}`` -- populated during validation. + compiled = None + pages_total = None + + def validate_file(self, f): + name = (f.name or "").lower() + ext = "." + name.rsplit(".", 1)[-1] if "." in name else "" + if ext not in ALLOWED_EXTENSIONS: + raise serializers.ValidationError( + f"Unsupported file type '{ext}'; allowed: {sorted(ALLOWED_EXTENSIONS)}" + ) + max_bytes = settings.AGENT_KV_MAX_FILE_SIZE_MB * 1024 * 1024 + if f.size > max_bytes: + raise serializers.ValidationError( + f"File exceeds {settings.AGENT_KV_MAX_FILE_SIZE_MB}MB limit" + ) + return f + + def validate_timeout(self, v): + if v > settings.AGENT_KV_MAX_TIMEOUT_SECONDS: + raise serializers.ValidationError( + f"timeout must be 0..{settings.AGENT_KV_MAX_TIMEOUT_SECONDS}" + ) + return v + + def validate_extractors(self, raw): + # §7.1: `extractors` may arrive as an inline JSON string OR a file part; + # SubmitView reads a file-typed part into a string before constructing + # the serializer, exactly as it did for the old `keys` field. + if len(raw.encode("utf-8")) > settings.AGENT_KV_MAX_SCHEMA_BYTES: + raise serializers.ValidationError("extractors payload too large") + try: + entries = json.loads(raw) + except (ValueError, TypeError) as e: + raise serializers.ValidationError(f"extractors is not valid JSON: {e}") + if not isinstance(entries, list) or not entries: + raise serializers.ValidationError("extractors must be a non-empty JSON array") + if len(entries) > 1: + # The FORMAT is being fixed before launch; the fan-out execution is + # not built (one executor exists, and page images are not shared). + # Refusing loudly beats accepting a request we would silently run + # single-extractor. + raise serializers.ValidationError( + "multiple extractors are not supported yet; pass exactly one" + ) + + validated, compiled = [], {} + for i, entry in enumerate(entries): + if not isinstance(entry, dict): + raise serializers.ValidationError(f"extractors[{i}] must be an object") + ser = ExtractorSerializer(data=entry) + if not ser.is_valid(): + raise serializers.ValidationError({f"extractors[{i}]": ser.errors}) + validated.append(ser.validated_data) + compiled[ser.validated_data["name"]] = ser.compiled + self.compiled = compiled + return validated + + def validate(self, data): + start, end = data.get("page_start", 1), data.get("page_end") + if end is not None and end < start: + raise serializers.ValidationError( + {"page_end": "page_end must be >= page_start"} + ) + f = data["file"] + ext = "." + f.name.lower().rsplit(".", 1)[-1] + if ext in PDF_LIKE: + try: + with pdfplumber.open(f) as pdf: + self.pages_total = len(pdf.pages) + except Exception: + raise serializers.ValidationError({"file": "Unreadable PDF"}) + finally: + f.seek(0) + if self.pages_total > settings.AGENT_KV_MAX_PAGES: + raise serializers.ValidationError( + { + "file": f"Document has {self.pages_total} pages; " + f"max is {settings.AGENT_KV_MAX_PAGES} (§6.1)" + } + ) + # Excel: no page concept pre-OCR (spec §6.1); pages_total stays None, + # size cap already enforced; the engine enforces the post-OCR cap. + return data diff --git a/backend/agent_kv/execution_urls.py b/backend/agent_kv/execution_urls.py new file mode 100644 index 0000000000..eff4807722 --- /dev/null +++ b/backend/agent_kv/execution_urls.py @@ -0,0 +1,29 @@ +"""Public Agent-KV execution routes. + +``ValidateView`` is **deliberately not routed here.** It compiles a `kv` keys +schema through ``unstract.agent_kv_schema.compile_schema`` and does nothing +else; the `table` extractor's ``keys`` is ``{"target_table": ...}``, validated +by ``TableKeysSerializer`` at submit. Publishing an endpoint that validates +schemas for an extractor this deployment refuses with a 400 would be an +incoherent public contract. + +The view class and the schema package both stay in the tree. Deleting them +would force a content merge in the files the branch carrying the KV engine +rewrites most; leaving them makes restoring this endpoint one line. +""" + +from django.urls import path + +from agent_kv.execution_views import ( + JobCancelView, + JobResultView, + JobStatusView, + SubmitView, +) + +urlpatterns = [ + path("", SubmitView.as_view(), name="agent_kv_submit"), + path("", JobStatusView.as_view(), name="agent_kv_status"), + path("/result", JobResultView.as_view(), name="agent_kv_result"), + path("/cancel", JobCancelView.as_view(), name="agent_kv_cancel"), +] diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py new file mode 100644 index 0000000000..f2fbb324fc --- /dev/null +++ b/backend/agent_kv/execution_views.py @@ -0,0 +1,373 @@ +import logging +import time +from datetime import timedelta + +from django.conf import settings +from django.utils import timezone +from plugins import get_plugin +from rest_framework.response import Response +from rest_framework.views import APIView + +from agent_kv.constants import STAGE_NAMES_BY_EXTRACTOR +from agent_kv.dispatch import DispatchError, dispatch_job +from agent_kv.exceptions import EngineUnavailable, JobNotFound, RateLimited +from agent_kv.execution_serializers import SubmitSerializer +from agent_kv.execution_views_result import result_payload +from agent_kv.key_validator import AgentKVKeyValidator +from agent_kv.models import AgentKVJob, JobStatus +from agent_kv.rate_limiter import AgentKVConcurrencyLimiter, check_key_rate +from agent_kv.storage import delete_job_files, stage_input +from unstract.agent_kv_schema.compile import SchemaError, compile_schema + +logger = logging.getLogger(__name__) + + +def _get_job(agent_kv_key, job_id): + """Org-scoped lookup used by every job-scoped endpoint (spec §5.4). + + Unknown job_id and a job that belongs to a different org must be + indistinguishable to the caller, so both funnel through the same + ``DoesNotExist`` -> ``JobNotFound`` (404) path. + """ + try: + return AgentKVJob.objects.get( + id=job_id, organization_id=agent_kv_key.organization_id + ) + except AgentKVJob.DoesNotExist: + raise JobNotFound() + + +def _status_document(job) -> dict: + """Build the status document per spec §7.2.""" + stages_json = job.stages or {} + # `.get`, not a subscript. No row can hold an out-of-dict value today (the + # sole creation site is serializer-validated and the migration backfills + # "kv"), but a RETIRED extractor name with surviving rows would 500 every + # `GET status` for those jobs while `GET result` kept working -- the result + # payload keys by `job.extractor` without consulting this table at all. An + # empty stage list degrades to "no stages reported", which is honest for an + # extractor this build no longer knows how to describe. + stage_names = STAGE_NAMES_BY_EXTRACTOR.get(job.extractor) + if stage_names is None: + logger.warning( + "agent-kv job %s ran extractor %r, which has no stage list in " + "STAGE_NAMES_BY_EXTRACTOR; reporting an empty stage array", + job.id, + job.extractor, + ) + stage_names = [] + doc = { + "job_id": str(job.id), + # The JOB's state, not an extractor's: a job is not complete until every + # extractor is, so this stays top level (spec §7.2). + "status": job.status.lower(), + # Stage names are extractor-specific (`qa`/`challenge`/`codegen` mean + # nothing to the table extractor) and they ARE returned to clients, so + # they are wire format and namespaced with everything else. + "extractors": { + job.extractor: { + "stage": job.stage, + "stages": [ + {"name": name, **stages_json[name]} + for name in stage_names + if name in stages_json + ], + } + }, + "created_at": job.created_at.isoformat() if job.created_at else None, + "started_at": job.dispatched_at.isoformat() if job.dispatched_at else None, + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "pages_total": job.pages_total, + } + if job.status == JobStatus.FAILED: + doc["error"] = job.error + return doc + + +def _fail_job_response(job, org_id: str, message: str, *, job_saved: bool) -> Response: + """Shared cleanup for any post-acquire submit failure (spec §5.3/§5.4). + + Always releases the concurrency slot acquired earlier in the request. + Only calls ``mark_terminal`` when a job row may actually exist — calling + it against a job_id with no row is harmless (the guarded UPDATE just + matches zero rows), but ``job_saved`` keeps the write guard's intent + (only terminalize rows that exist) obvious at the call site. + """ + if job_saved: + AgentKVJob.mark_terminal( + job.id, job.organization_id, JobStatus.FAILED, error=message + ) + AgentKVConcurrencyLimiter.release(org_id, str(job.id)) + return Response( + {"job_id": str(job.id), "status": JobStatus.FAILED.lower(), "error": message}, + status=500, + ) + + +class SubmitView(APIView): + authentication_classes: list = [] + permission_classes: list = [] + + @AgentKVKeyValidator.validate_api_key + def post(self, request, *args, agent_kv_key=None, **kwargs): + plugin = get_plugin("agent_kv") + if not plugin: + raise EngineUnavailable() + if not check_key_rate(str(agent_kv_key.id)): + raise RateLimited() + + # Subscription admission (§6.6). A submit dispatches paid work, so it is + # gated exactly as an API deployment execute is -- same policy, same 402 + # bodies -- via the cloud plugin's gate, which calls the very + # `SubscriptionHelper` that cloud's `SubscriptionMiddleware` calls. + # + # Why here and not in that middleware: it resolves the org from the URL + # (`/deployment/api/{org_name}/...`). Agent-KV's URL carries no org + # segment -- the org is inside the Bearer key -- so the middleware's + # `get_organization_id` returns None for these requests, finds no + # subscription row, and admits every one of them. This is the first + # point where the org is actually known. + # + # `organization.organization_id` is the org SLUG -- the CharField + # `Subscription.organization_id` is keyed on, and what the deployment + # URL supplies as `org_name`. NOT `agent_kv_key.organization_id`, which + # is the Organization FK primary key: that matches no row, and the + # shared policy reads "no row" as "nothing to enforce", so the gate + # would silently admit everything while looking correctly wired. + gate_factory = plugin.get("service_class") + if gate_factory: # absent on a cloud build predating the gate + denied = gate_factory().check( + agent_kv_key.organization.organization_id, request + ) + if denied is not None: + return denied + + data = request.data.copy() + part = data.get("extractors") + if hasattr(part, "read"): # `extractors` uploaded as a file part (§7.1) + data["extractors"] = part.read().decode("utf-8", errors="replace") + serializer = SubmitSerializer(data=data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + org_id = str(agent_kv_key.organization_id) + + job = AgentKVJob( + api_key=agent_kv_key, + organization_id=agent_kv_key.organization_id, + extractor=v["extractors"][0]["name"], + pages_total=serializer.pages_total, + tags=v["tags"], + custom_data=v["custom_data"], + webhook_url=v["webhook_url"], + expires_at=timezone.now() + timedelta(days=settings.AGENT_KV_RESULT_TTL_DAYS), + ) + if not AgentKVConcurrencyLimiter.check_and_acquire(org_id, str(job.id)): + raise RateLimited("Concurrent job limit reached") + + job_saved = False + try: + job.input_ref = stage_input(org_id, str(job.id), v["file"]) + job.save() + job_saved = True + except Exception: + logger.exception("agent-kv staging/save failed for job %s", job.id) + return _fail_job_response( + job, + org_id, + "Job could not be accepted; nothing was billed.", + job_saved=job_saved, + ) + + # Unpack the single extractor entry into the FROZEN OSS<->cloud + # executor_params contract (`schema` + `options`). The wire format + # changed at the API edge only: the engine still receives exactly the + # option names it always did, so no cloud change is required. + entry = v["extractors"][0] + options = dict(entry["options"]) + # Job-level, but the engine reads them from `options` (§7.1: the page + # range drives the shared OCR pass, so it cannot be per-extractor). + options["page_start"] = v["page_start"] + options["page_end"] = v["page_end"] + try: + dispatch_job( + job, extractor=entry["name"], schema=entry["keys"], options=options + ) + except DispatchError: + logger.exception("agent-kv dispatch failed for job %s", job.id) + return _fail_job_response( + job, + org_id, + "Job could not be dispatched; nothing was billed.", + job_saved=True, + ) + except Exception: + # Belt-and-braces: dispatch_job wraps its own internal failures + # as DispatchError, but nothing here may rely on that alone — + # any other exception must still terminalize the job and + # release the slot rather than escape as an unhandled 500. + logger.exception( + "agent-kv dispatch raised unexpectedly for job %s", + job.id, + ) + return _fail_job_response( + job, + org_id, + "Job could not be dispatched; nothing was billed.", + job_saved=True, + ) + + wait = v["timeout"] + if wait: + deadline = time.monotonic() + wait + while time.monotonic() < deadline: + job.refresh_from_db() + if job.status in AgentKVJob.TERMINAL: + from agent_kv.execution_views_result import result_payload + + return Response(result_payload(job), status=200) + time.sleep(1) + + return Response( + { + "job_id": str(job.id), + # Lowercased for cross-endpoint consistency (spec §7.2) -- the + # 202 body used to leak the raw uppercase status. + "status": job.status.lower(), + "status_url": f"/{settings.AGENT_KV_PATH_PREFIX}/{job.id}", + "created_at": job.created_at.isoformat(), + }, + status=202, + ) + + +class ValidateView(APIView): + authentication_classes: list = [] + permission_classes: list = [] + + @AgentKVKeyValidator.validate_api_key + def post(self, request, *args, agent_kv_key=None, **kwargs): + if not check_key_rate(str(agent_kv_key.id)): + raise RateLimited() + spec = request.data.get("keys") + if spec is None: + return Response({"detail": "body must include 'keys'"}, status=400) + try: + compiled = compile_schema(spec) + except SchemaError as e: + return Response({"valid": False, "error": str(e)}, status=200) + return Response( + { + "valid": True, + "leaves": len(compiled.key_specs), + "arrays": len(compiled.array_specs), + "constraints": len(compiled.constraints), + }, + status=200, + ) + + +class JobStatusView(APIView): + """GET status document; DELETE purges the job's staged/result files. + + DELETE is merged onto this class (rather than a standalone + ``JobDeleteView``) because both share the ```` URL — Django + matches a URL pattern once per request regardless of HTTP method, so two + separate ``path()`` entries for the same literal path can't coexist. + ``JobDeleteView`` below is kept as a name alias for this same class. + """ + + authentication_classes: list = [] + permission_classes: list = [] + + @AgentKVKeyValidator.validate_api_key + def get(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): + job = _get_job(agent_kv_key, job_id) + return Response(_status_document(job), status=200) + + @AgentKVKeyValidator.validate_api_key + def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): + job = _get_job(agent_kv_key, job_id) + if job.status not in AgentKVJob.TERMINAL: + # Cancel BEFORE deleting files: a still-running job would + # otherwise keep running after its files are gone, and its + # eventual finalize call would write a fresh result_ref onto a + # job the caller already asked to delete -- resurrecting a + # result they explicitly discarded. Terminalizing first closes + # that window; a finalize call that still lands late loses the + # terminal-state guard and, on the success path, cleans up its + # own now-orphaned write (FinalizeView, storage.delete_result_file). + won = AgentKVJob.mark_terminal( + job.id, job.organization_id, JobStatus.CANCELLED + ) + if won: + # Release the concurrency slot, exactly as JobCancelView does + # and for the same reason: the slot is taken at submit and + # released by _fail_job_response, the finalize callback and the + # sweep -- but a job terminalized HERE before dispatch gets no + # finalize callback, and the sweep's phase-1 only targets + # PENDING, never CANCELLED. Without this, deleting in-flight + # jobs leaks a slot each until the 6h TTL, and enough deletes + # exhaust the org's allowance and start rejecting new submits. + # + # Guarded on `won` so a lost race (a concurrent cancel or + # finalize terminalized it first) does not release a slot that + # the winner is still accounting for; release() is idempotent + # (zrem), so the winner's own release stays correct. + AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) + # Blank only the refs whose files are confirmed gone, so a ref whose + # delete failed survives as the handle TTL cleanup retries from. 204 + # either way: the job IS terminal and the caller's intent is recorded, + # and a sync 5xx here would only invite a retry of a DELETE that already + # did everything it could. + cleared = delete_job_files(job) + if cleared: + for field in cleared: + setattr(job, field, "") + job.save(update_fields=cleared) + return Response(status=204) + + +# Alias kept for a descriptive import name; there is no separate URL route +# (see the JobStatusView docstring above) — DELETE rides JobStatusView's URL. +JobDeleteView = JobStatusView + + +class JobResultView(APIView): + authentication_classes: list = [] + permission_classes: list = [] + + @AgentKVKeyValidator.validate_api_key + def get(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): + job = _get_job(agent_kv_key, job_id) + if job.status not in AgentKVJob.TERMINAL: + return Response({"status": job.status.lower()}, status=409) + # A job's row outlives its result by design (audit trail after TTL + # cleanup blanks the refs) -- expired or a COMPLETED job whose + # result was already swept both mean "nothing left to serve". + if (job.expires_at and job.expires_at < timezone.now()) or ( + job.status == JobStatus.COMPLETED and not job.result_ref + ): + raise JobNotFound() + return Response(result_payload(job), status=200) + + +class JobCancelView(APIView): + authentication_classes: list = [] + permission_classes: list = [] + + @AgentKVKeyValidator.validate_api_key + def post(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): + job = _get_job(agent_kv_key, job_id) + won = AgentKVJob.mark_terminal(job.id, job.organization_id, JobStatus.CANCELLED) + if won: + # The slot is acquired at submit and released by _fail_job_response, + # the finalize callback, and the sweep. A job cancelled BEFORE + # dispatch gets no finalize callback, and the sweep's phase-1 only + # targets PENDING (not CANCELLED) -- so without this release its + # slot would leak until the 6h TTL (pre-Greptile important #4). + # release() is idempotent (zrem), so a later finalize-callback + # release on a cancel-mid-run is harmless. + AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) + return Response({"status": "cancelled"}, status=200) + # Lowercased for cross-endpoint consistency (spec §7.2). + return Response({"status": job.status.lower()}, status=409) diff --git a/backend/agent_kv/execution_views_result.py b/backend/agent_kv/execution_views_result.py new file mode 100644 index 0000000000..5ec465a604 --- /dev/null +++ b/backend/agent_kv/execution_views_result.py @@ -0,0 +1,63 @@ +"""Result/outcome payload for a TERMINAL agent-kv job (spec §7.3). + +Single source of truth for "what does the result endpoint hand back for a +job that's done", shared by ``JobResultView`` (``execution_views.py``) and +``SubmitView``'s synchronous-wait branch (Task 8, lazily imported to avoid a +circular import at module load time) -- both call this only once they've +already established the job is terminal, so it is total over +``AgentKVJob.TERMINAL`` and never raises for a terminal job (spec §7.3: +"Failed jobs: {success: false, error, timing} with a user-safe error"). + +Everything that depends on the caller's own status code (409 for a +non-terminal job at ``JobResultView``, vs. a plain 200 for any terminal +status at ``SubmitView``'s wait loop) -- and the expired/blank-``result_ref`` +404 check -- lives in the caller instead; this function's only job is +building the right body once "terminal" is already a given. +""" + +from agent_kv.models import JobStatus +from agent_kv.storage import read_result + + +def result_payload(job) -> dict: + """Return the result/outcome payload for a terminal ``job``. + + - COMPLETED: ``{success, status, extractors, usage_summary}`` -- the stored + engine result namespaced per extractor (spec §7.3), not the bare blob. + - FAILED: ``{"success": False, "status": "failed", "error": }``. + - CANCELLED: ``{"success": False, "status": "cancelled"}``. + + Callers are responsible for confirming ``job.status`` is terminal (and, + for COMPLETED, that ``result_ref`` is non-blank and unexpired) before + calling this -- it never raises for a job in any of the three states + above. + """ + if job.status == JobStatus.COMPLETED: + # Wrapped at READ time, not stored wrapped: the stored blob is the + # engine's own result object, and namespacing it here keeps the cloud + # engine and every already-written result untouched (spec §7.3). + usage = job.usage_summary or {} + return { + # `success`/`status` stay at TOP level on every terminal payload -- + # completed, failed and cancelled alike -- so a client branches on + # one key rather than having to know that only the failure shapes + # carry it. They describe the JOB; each extractor keeps its own + # `success` inside its own block. + "success": True, + "status": JobStatus.COMPLETED.lower(), + "extractors": {job.extractor: read_result(job.result_ref)}, + # `total` stays the authoritative billing figure; `by_extractor` + # exists so a multi-extractor job can be attributed. With one + # extractor they are necessarily the same numbers. + "usage_summary": { + "total": usage, + "by_extractor": {job.extractor: usage}, + }, + } + if job.status == JobStatus.FAILED: + return { + "success": False, + "status": "failed", + "error": job.error or "Job failed", + } + return {"success": False, "status": "cancelled"} diff --git a/backend/agent_kv/internal_urls.py b/backend/agent_kv/internal_urls.py new file mode 100644 index 0000000000..2ebffc2b21 --- /dev/null +++ b/backend/agent_kv/internal_urls.py @@ -0,0 +1,31 @@ +from django.urls import path + +from agent_kv.internal_views import ( + FinalizeView, + StageReportView, + SweepView, + TTLCleanupView, +) + +urlpatterns = [ + path( + "jobs//stage/", + StageReportView.as_view(), + name="agent_kv_internal_stage", + ), + path( + "jobs//finalize/", + FinalizeView.as_view(), + name="agent_kv_internal_finalize", + ), + path( + "sweep/", + SweepView.as_view(), + name="agent_kv_internal_sweep", + ), + path( + "ttl-cleanup/", + TTLCleanupView.as_view(), + name="agent_kv_internal_ttl_cleanup", + ), +] diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py new file mode 100644 index 0000000000..744a571bf9 --- /dev/null +++ b/backend/agent_kv/internal_views.py @@ -0,0 +1,267 @@ +"""Internal APIs for the agent-kv cloud executor (spec §5.4). + +Mounted under ``/internal/v1/agent-kv/`` and guarded ambiently by +``InternalAPIAuthMiddleware`` -- these views take empty auth/permission +classes. Most require ``org_id`` in the body of every request (they act on +one job belonging to one org); the two maintenance views, ``SweepView`` and +``TTLCleanupView``, are the exception -- they are platform-wide and take no +``org_id`` at all, since they sweep across every org in one call. +""" + +import logging + +from django.db.models import F, JSONField, Value +from django.db.models.expressions import CombinedExpression +from rest_framework.response import Response +from rest_framework.views import APIView + +from agent_kv.maintenance import run_sweep, run_ttl_cleanup +from agent_kv.models import AgentKVJob, JobStatus +from agent_kv.rate_limiter import AgentKVConcurrencyLimiter +from agent_kv.storage import delete_input, delete_result_file, write_result + +logger = logging.getLogger(__name__) + +_VALID_STAGE_STATUSES = frozenset({"running", "done"}) +_RESERVED_STAGE_ENTRY_KEYS = frozenset({"status", "seconds"}) +_SCALAR_COUNTER_TYPES = (str, int, float, bool) + + +def _sanitize_counters(counters) -> dict: + """Keep only scalar counters that can't clobber the reserved fields. + + Task-11-review ruling: ``counters`` is untrusted executor payload -- + a key of ``status``/``seconds`` must not be able to override the + endpoint's own reserved fields, and a nested dict/list value must not + be admitted into the persisted stage entry (only scalars are). + """ + if not isinstance(counters, dict): + return {} + return { + key: value + for key, value in counters.items() + if key not in _RESERVED_STAGE_ENTRY_KEYS + and isinstance(value, _SCALAR_COUNTER_TYPES) + } + + +def _stage_merge_expression(stage: str, entry: dict) -> CombinedExpression: + """Build a DB-side jsonb merge expression for one stage's entry. + + Compiles (on Postgres) to ``"stages" || %s``, the jsonb ``||`` + concatenation operator, which does a shallow top-level merge: only the + ``stage`` key in the JSON column is touched, every other key is left + exactly as the current row has it. This is what makes the merge safe + against two concurrent stage reports for two *different* stages racing + each other -- a Python-side ``dict(job.stages or {})`` read, merge, and + full-column ``.update(stages=...)`` would let whichever write lands + second silently clobber the first (task-11-review ruling). + """ + return CombinedExpression( + F("stages"), + "||", + Value({stage: entry}, output_field=JSONField()), + output_field=JSONField(), + ) + + +class StageReportView(APIView): + """Merge one stage's progress into ``job.stages`` (spec §5.4). + + This endpoint is the sole write gate for ``job.stages``: only the + defined shape (``status``, optional ``seconds``, plus sanitized flat + ``counters``) is ever persisted -- unexpected top-level body keys, and + any counter that collides with a reserved key or isn't a scalar, are + dropped rather than stored, so a later status-endpoint spread of a + stage entry can't leak arbitrary payload content (task-9 review). + """ + + authentication_classes: list = [] + permission_classes: list = [] + + def post(self, request, *args, job_id=None, **kwargs): + body = request.data + org_id = body.get("org_id") + if not org_id: + return Response({"detail": "org_id is required"}, status=400) + stage = body.get("stage") + if not stage: + return Response({"detail": "stage is required"}, status=400) + status_value = body.get("status") + if status_value not in _VALID_STAGE_STATUSES: + return Response({"detail": "status must be 'running' or 'done'"}, status=400) + + job_qs = AgentKVJob.objects.filter(id=job_id, organization_id=org_id).exclude( + status__in=list(AgentKVJob.TERMINAL) + ) + job = job_qs.first() + if job is None: + # Late report for a job that already reached a terminal state + # (completed/failed/cancelled) -- a no-op, not an error. + return Response({"ok": True, "noop": True}) + + entry = {"status": status_value} + if "seconds" in body: + entry["seconds"] = body["seconds"] + entry.update(_sanitize_counters(body.get("counters"))) + + updates = { + "stages": _stage_merge_expression(stage, entry), + "stage": stage, + } + if job.status in (JobStatus.PENDING, JobStatus.DISPATCHED): + updates["status"] = JobStatus.RUNNING + # Re-guarded at update time: job_qs still excludes TERMINAL, so if + # the job raced to terminal between the read above and this + # UPDATE's WHERE clause, this becomes a silent no-op too. + job_qs.update(**updates) + return Response({"ok": True}) + + +class FinalizeView(APIView): + """Terminalize a job: write its result (on success) then mark it done. + + Idempotent: a job already in a terminal state short-circuits before any + write -- ``write_result`` is only ever called for a job that is still + non-terminal at read time, so a duplicate/late finalize call can never + rewrite an already-written result. The concurrency slot is always + released, in a ``finally``, on every path that actually attempts to + finalize (success, duplicate no-op, or an exception raised while + finalizing) -- but never on a 400 for a malformed body, since nothing + was finalized (and therefore nothing had a slot to release yet). + + On the success path, ``write_result`` unavoidably runs *before* the + ``mark_terminal`` guard (the result has to exist to get a ``result_ref`` + to pass into it) -- so a guard-loss right after the write (a concurrent + cancel/duplicate finalize won instead) leaves a result file on disk with + no ref anywhere pointing at it. That orphan is cleaned up immediately via + ``storage.delete_result_file`` rather than left to leak past even TTL + cleanup (which only ever acts on a job's *stored* ``result_ref``). + + Also deletes the staged input the moment ``mark_terminal`` actually + wins (spec D10: "uploaded document deleted on job completion") -- the + run is over either way, whether it completed or failed, so the input is + no longer needed; the result file this same request may have just + *successfully* written (guard won) is left completely untouched. Never + runs on the duplicate/guard-lost path, since either another writer + already owns cleanup or there's nothing new to terminalize. + """ + + authentication_classes: list = [] + permission_classes: list = [] + + def post(self, request, *args, job_id=None, **kwargs): + body = request.data + org_id = body.get("org_id") + if not org_id: + return Response({"detail": "org_id is required"}, status=400) + success = body.get("success") + if not isinstance(success, bool): + # Strict bool check (not truthy/falsy): a malformed/missing + # `success` must 400 rather than silently fall through to the + # failure branch and persist a FAILED job with an empty error. + return Response({"detail": "success must be a boolean"}, status=400) + + job = AgentKVJob.objects.filter(id=job_id, organization_id=org_id).first() + finalized = False + try: + if job is not None and job.status not in AgentKVJob.TERMINAL: + if success: + result_ref = write_result( + org_id, str(job_id), body.get("result") or {} + ) + finalized = AgentKVJob.mark_terminal( + job_id, + org_id, + JobStatus.COMPLETED, + result_ref=result_ref, + usage_summary=body.get("usage_summary"), + ) + if finalized: + job.status = JobStatus.COMPLETED + else: + # Guard lost after the write above (e.g. a cancel + # raced in between the read and mark_terminal's + # guarded UPDATE): the result we just wrote has no + # ref anywhere -- unreachable forever unless cleaned + # up here. Best-effort; never blocks the response. + delete_result_file(result_ref) + else: + finalized = AgentKVJob.mark_terminal( + job_id, + org_id, + JobStatus.FAILED, + error=body.get("error") or "", + ) + if finalized: + job.status = JobStatus.FAILED + + if finalized: + # A CANCELLED job never reaches here: cancel goes + # through JobCancelView/mark_terminal directly, not + # this finalize path, and a late finalize call against + # an already-CANCELLED job loses the terminal guard + # above -- so a cancelled job's input intentionally + # rides the normal TTL sweep instead of being deleted + # here. + # Blank the ref only when the file is confirmed gone -- + # otherwise it is the only handle TTL cleanup can retry + # from, and blanking it orphans the uploaded document. + if delete_input(job): + AgentKVJob.objects.filter( + id=job_id, organization_id=org_id + ).update(input_ref="") + finally: + AgentKVConcurrencyLimiter.release(org_id, str(job_id)) + + return Response( + { + "finalized": finalized, + "webhook_url": job.webhook_url if job else "", + "status": job.status.lower() if job else "", + } + ) + + +class SweepView(APIView): + """Terminalize PENDING-never-dispatched AND stuck jobs (spec §5.4). + + Thin wrapper: the two-phase sweep logic itself -- never-dispatched + PENDING rows past ``AGENT_KV_SWEEP_GRACE_SECONDS`` and stuck + DISPATCHED/RUNNING rows past ``AGENT_KV_STUCK_JOB_GRACE_SECONDS``, each + idempotent and batch-capped -- lives in + :func:`agent_kv.maintenance.run_sweep`, shared with the + ``agent_kv_sweep`` management command (the Kubernetes CronJob mechanism + for the cloud deployment). See that function's docstring for the full + rationale. + + Platform-wide by design (no ``org_id`` in the body, unlike every other + view in this module) -- it is invoked by a periodic maintenance + mechanism (spec §5.4), not the cloud executor acting on one job/org. + """ + + authentication_classes: list = [] + permission_classes: list = [] + + def post(self, request, *args, **kwargs): + return Response(run_sweep()) + + +class TTLCleanupView(APIView): + """Delete staged files for expired jobs and blank their refs (spec §5.4). + + Thin wrapper: the cleanup logic itself -- deleting object-store paths + for expired jobs and blanking their refs, idempotent and batch-capped -- + lives in :func:`agent_kv.maintenance.run_ttl_cleanup`, shared with the + ``agent_kv_ttl_cleanup`` management command (the Kubernetes CronJob + mechanism for the cloud deployment). See that function's docstring for + the full rationale. + + Platform-wide by design, same as :class:`SweepView`. + """ + + authentication_classes: list = [] + permission_classes: list = [] + + def post(self, request, *args, **kwargs): + return Response(run_ttl_cleanup()) diff --git a/backend/agent_kv/key_validator.py b/backend/agent_kv/key_validator.py new file mode 100644 index 0000000000..2daa729cbc --- /dev/null +++ b/backend/agent_kv/key_validator.py @@ -0,0 +1,45 @@ +import logging +import uuid + +from api_v2.api_key_validator import BaseAPIKeyValidator +from api_v2.exceptions import Forbidden + +from agent_kv.models import AgentKVKey + +logger = logging.getLogger(__name__) + +# One message for every rejection reason, deliberately. A caller holding a bad +# key learns only that it is bad -- not whether it was malformed, unknown, +# inactive, or missing an organization -- so the response cannot be used to +# probe which keys exist. Named once so the three raise sites cannot drift +# apart and start leaking that distinction. +_INVALID_KEY = "Invalid api key" + + +class AgentKVKeyValidator(BaseAPIKeyValidator): + @staticmethod + def validate_and_process(self, request, func, api_key, *args, **kwargs): + try: + uuid.UUID(api_key) + except (ValueError, AttributeError): + raise Forbidden(_INVALID_KEY) + try: + key_obj = AgentKVKey.objects.get(key=api_key, is_active=True) + except AgentKVKey.DoesNotExist: + raise Forbidden(_INVALID_KEY) + if key_obj.organization_id is None: + # `organization` is nullable on the model (DefaultOrganizationMixin + # fills it from UserContext at save time), but EVERY downstream use + # of a key is org-scoped: the subscription gate, the concurrency + # limiter, the storage prefix and every job lookup. A key with no + # organization cannot scope any of them, so it is not a usable key. + # Refused once here, at the auth boundary, rather than surfacing as + # a null dereference inside whichever view happens to touch the + # organization first. + logger.error( + "agent-kv key %s has no organization; refusing the request", + key_obj.id, + ) + raise Forbidden(_INVALID_KEY) + kwargs["agent_kv_key"] = key_obj + return func(self, request, *args, **kwargs) diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py new file mode 100644 index 0000000000..a448030849 --- /dev/null +++ b/backend/agent_kv/maintenance.py @@ -0,0 +1,249 @@ +"""Agent-KV platform-wide maintenance logic (spec §5.4). + +Two independent, idempotent, batch-capped jobs -- the never-dispatched/ +stuck-job sweep and the TTL cleanup of expired staged files -- live here so +there is exactly one implementation shared by both invocation paths: + +* the internal HTTP endpoints (``agent_kv/internal_views.py::SweepView``/ + ``TTLCleanupView``, ``POST /internal/v1/agent-kv/sweep/`` and + ``.../ttl-cleanup/``), driven by the OSS/self-hosted PG-scheduler periodic + task mechanism (``workers/scheduler/agent_kv_tasks.py``); and +* the ``agent_kv_sweep``/``agent_kv_ttl_cleanup`` Django management commands + (``agent_kv/management/commands/``), driven by a Kubernetes CronJob in the + cloud deployment. + +Both callers get the identical dict shape back (``{"swept": N, "timed_out": +M}`` / ``{"cleaned": N}``), and both entrypoints are equally safe to call +more often than needed, or concurrently with each other -- everything below +is either a guarded UPDATE (``AgentKVJob.mark_terminal``) or a targeted +single-row write, never a queryset-wide one. +""" + +from datetime import timedelta + +from django.conf import settings +from django.db.models import Q +from django.utils import timezone + +from agent_kv.models import AgentKVJob, JobStatus +from agent_kv.rate_limiter import AgentKVConcurrencyLimiter +from agent_kv.storage import delete_job_files + +_MAINTENANCE_BATCH_LIMIT = 500 +# Slots in each TTL-cleanup batch held for rows whose file delete failed before. +# Caps the retry lane (so failures cannot crowd out new expirations) and +# guarantees it (so new expirations cannot crowd out retries) -- review found +# the implementation starving each side in turn when one ordering tried to do +# both. 20% leaves 400 slots for the normal case, where nothing is retrying. +_TTL_RETRY_RESERVE = 100 +_NEVER_DISPATCHED_ERROR = "Job was never dispatched" +_STUCK_JOB_ERROR = "Job timed out" + + +def run_sweep() -> dict: + """Terminalize PENDING-never-dispatched AND stuck jobs (spec §5.4). + + Two independent phases, run every call, each capped and counted + separately: + + **Phase 1 -- never dispatched.** Mirrors ``workflow_manager``'s + undispatched-execution sweep: the submit endpoint commits a PENDING row + before dispatch runs, and an abort in between (client disconnect, worker + crash, pod eviction) can leave it stranded with no owner -- ``PENDING`` + is not a terminal state, and nothing else recovers a job that was never + queued. ``dispatched_at`` is stamped as a positive fact at dispatch + time, so PENDING + older than the grace + ``dispatched_at IS NULL`` + means the dispatch never happened. + + **Phase 2 -- stuck in flight.** A job that *did* dispatch can still + never terminalize: the executor pod can be killed, its callback queue + can be lost, or the cloud engine itself can hang -- none of which + ``mark_terminal`` ever sees, so a DISPATCHED/RUNNING row can sit + forever holding a concurrency slot with no path back to terminal. + ``dispatched_at`` (stamped positively at dispatch, spec §5.3/Fix 1) older + than ``AGENT_KV_STUCK_JOB_GRACE_SECONDS`` is the only signal available -- + there is no heartbeat -- so this phase force-fails anything past that + grace, same as the workflow reaper's stuck-execution recovery. + + Platform-wide by design (no ``org_id`` parameter) -- invoked by a + periodic maintenance mechanism (spec §5.4), not by something acting on + one job/org. + + Idempotent: ``mark_terminal``'s guarded UPDATE only terminalizes a row + still in a non-terminal state, so a job already swept (or one that + legitimately dispatched/finalized/was cancelled since the candidate + query ran) is left alone by a repeat call. ``swept``/``timed_out`` each + count guard successes, not candidates, so a race against a concurrent + finalize/cancel/duplicate sweep is reflected accurately instead of + double-counted. + + Each phase is independently batch-capped at ``_MAINTENANCE_BATCH_LIMIT`` + (oldest-first by its own ordering key) so a large backlog in either + phase -- exactly what a dispatch-path or executor-fleet infra incident + produces, which is also when this sweep matters most -- can't load + unbounded into memory or hold a caller open through a long loop. + Idempotency (above) is what makes this safe to cap: whatever a call + doesn't reach is still there, unchanged, for the next tick. + """ + now = timezone.now() + + never_dispatched_cutoff = now - timedelta( + seconds=settings.AGENT_KV_SWEEP_GRACE_SECONDS + ) + never_dispatched_candidates = AgentKVJob.objects.filter( + status=JobStatus.PENDING, + created_at__lt=never_dispatched_cutoff, + dispatched_at__isnull=True, + ).order_by("created_at")[:_MAINTENANCE_BATCH_LIMIT] + + swept = 0 + for job in never_dispatched_candidates: + # Per-job isolation is structural, not a try/except here: + # mark_terminal is a guarded UPDATE that can't raise on a + # normal outcome, and release() has its own internal + # try/except (rate_limiter.py) -- so one job's failure can't + # abort the loop for the rest of the batch. + org_id = job.organization_id + won = AgentKVJob.mark_terminal( + job.id, + org_id, + JobStatus.FAILED, + error=_NEVER_DISPATCHED_ERROR, + ) + if won: + swept += 1 + # Only a job this call actually terminalized held a slot + # worth releasing here -- one a concurrent finalize/cancel + # won instead already released its own slot on that path. + AgentKVConcurrencyLimiter.release(str(org_id), str(job.id)) + + stuck_cutoff = now - timedelta(seconds=settings.AGENT_KV_STUCK_JOB_GRACE_SECONDS) + # `dispatched_at__lt` OR `dispatched_at IS NULL`, not just the former. + # A DISPATCHED/RUNNING row whose `dispatched_at` is NULL is unreachable by + # a `__lt` filter alone -- SQL `NULL < x` is never true -- so such a row + # would hang in a non-terminal state forever. dispatch.py now stamps + # `dispatched_at` for any non-terminal row precisely so this cannot + # normally happen; this arm is the backstop for the window that remains + # (the worker dying between the enqueue and that stamp), because the cost + # of missing one is a job that never terminalizes at all. Such a row falls + # back to `created_at` for the age test, which is the only timestamp it + # has. + stuck_candidates = ( + AgentKVJob.objects.filter( + status__in=[JobStatus.DISPATCHED, JobStatus.RUNNING], + ) + .filter( + Q(dispatched_at__lt=stuck_cutoff) + | Q(dispatched_at__isnull=True, created_at__lt=stuck_cutoff) + ) + .order_by("dispatched_at")[:_MAINTENANCE_BATCH_LIMIT] + ) + + timed_out = 0 + for job in stuck_candidates: + org_id = job.organization_id + won = AgentKVJob.mark_terminal( + job.id, + org_id, + JobStatus.FAILED, + error=_STUCK_JOB_ERROR, + ) + if won: + timed_out += 1 + AgentKVConcurrencyLimiter.release(str(org_id), str(job.id)) + + return {"swept": swept, "timed_out": timed_out} + + +def run_ttl_cleanup() -> dict: + """Delete staged files for expired jobs and blank their refs (spec §5.4). + + The job row itself is retained (audit trail) -- only the object-store + paths are dropped, once nothing can read them any more (the status/ + result endpoints already 404 past ``expires_at`` -- Task 9). Blanking a ref + after its file is deleted is what makes a repeat call a no-op: the filters + below only match rows still carrying a non-blank ref, so a job already + cleaned (or one that never staged an input/produced a result) drops out of + the candidate set on the next pass. + + Platform-wide by design, same as :func:`run_sweep`. + + **Two queries, not one ordering.** Retaining a ref whose delete failed is + what makes a retry possible at all, but it also means failed rows compete + with new expirations for a capped batch, and either side can starve the + other. Both directions were observed in review: + + - Oldest-expiry-first alone: 500 permanently-failing rows refill every + batch and nothing newer is ever reached. + - ``cleanup_failed_at NULLS FIRST`` (the first attempt at a fix): the + mirror image -- 500 fresh expirations per tick fill every batch and the + failures are never retried, so their files sit in storage indefinitely. + + One ordering cannot express "neither side starves the other", so the batch + is split instead: retries get up to ``_TTL_RETRY_RESERVE`` slots, fresh + rows get whatever is left. Each side is capped, so each side is guaranteed + capacity whenever it has work. + + The split also buys a cheaper plan. Each query now sorts by ONE column + ascending with a plain equality/IS NULL predicate on the index's leading + column, so the ``(cleanup_failed_at, expires_at)`` index serves both + directly. ``NULLS FIRST`` could not use it at all -- a btree index is + ``NULLS LAST`` by default -- so Postgres had to sort every matching expired + row before applying the limit, which grows with the backlog. + """ + now = timezone.now() + expired = AgentKVJob.objects.filter(expires_at__lt=now).filter( + Q(input_ref__gt="") | Q(result_ref__gt="") + ) + + # Retries first, capped at the reserve so they cannot crowd out fresh work. + # Oldest failure first, so failures rotate rather than one row absorbing + # every retry. + retries = list( + expired.filter(cleanup_failed_at__isnull=False).order_by("cleanup_failed_at")[ + :_TTL_RETRY_RESERVE + ] + ) + # Fresh rows take the remaining capacity -- the full batch when there is + # nothing to retry, which is the normal case. + fresh = list( + expired.filter(cleanup_failed_at__isnull=True).order_by("expires_at")[ + : _MAINTENANCE_BATCH_LIMIT - len(retries) + ] + ) + + cleaned = 0 + retained = 0 + for job in retries + fresh: + # Blank only the refs whose files are CONFIRMED gone. Targeted + # single-row update (not a queryset-wide `.update()`) for the same + # reason it always was: one job's refs must never be blanked off the + # back of another job's delete. + # + # A ref left set is the retry handle -- this loop used to blank both + # unconditionally, so a transient object-store failure orphaned the file + # permanently (the candidate filters above only match rows that still + # carry a non-blank ref, so a blanked row can never be reconsidered). + cleared = delete_job_files(job) + fields: dict = dict.fromkeys(cleared, "") + if len(cleared) == 2: + cleaned += 1 + # Fully cleaned rows drop out of the candidate filter anyway (both + # refs blank), so this only matters for a row that failed before and + # succeeded now -- it must not keep a stale failure marker, or it + # would consume a reserve slot it no longer needs. + fields["cleanup_failed_at"] = None + else: + retained += 1 + # Stamped on every failed attempt, not just the first: this is what + # moves the row into the retry lane, and refreshing it rotates the + # retry order among failures instead of letting the earliest-stamped + # row be retried forever. + fields["cleanup_failed_at"] = timezone.now() + AgentKVJob.objects.filter(id=job.id).update(**fields) + # `retained` is reported, not just logged: these rows keep their refs and + # are retried from the reserve lane on a later tick. A `retained` that + # stays high across ticks is the signal that something is wrong with the + # object store rather than with one job -- the split stops either side + # blocking the other, it does not make a persistent fault harmless. + return {"cleaned": cleaned, "retained": retained} diff --git a/backend/agent_kv/management/__init__.py b/backend/agent_kv/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/agent_kv/management/commands/__init__.py b/backend/agent_kv/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/agent_kv/management/commands/agent_kv_sweep.py b/backend/agent_kv/management/commands/agent_kv_sweep.py new file mode 100644 index 0000000000..9c091b9403 --- /dev/null +++ b/backend/agent_kv/management/commands/agent_kv_sweep.py @@ -0,0 +1,24 @@ +"""CronJob entrypoint for the Agent-KV never-dispatched/stuck-job sweep. + +Thin wrapper around :func:`agent_kv.maintenance.run_sweep` -- see that +function's docstring for the two-phase sweep logic itself (spec §5.4). This +command is the cloud mechanism for driving it: a Kubernetes CronJob runs +``python manage.py agent_kv_sweep`` on a schedule. The internal +``SweepView``/``POST /internal/v1/agent-kv/sweep/`` endpoint +(``agent_kv/internal_views.py``) delegates to the same function and remains +the OSS/self-hosted mechanism, driven by the PG-scheduler periodic task +(``workers/scheduler/agent_kv_tasks.py``). +""" + +import json + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Terminalize never-dispatched and stuck Agent-KV jobs (CronJob entrypoint)" + + def handle(self, *args, **options): + from agent_kv.maintenance import run_sweep + + self.stdout.write(json.dumps(run_sweep())) diff --git a/backend/agent_kv/management/commands/agent_kv_ttl_cleanup.py b/backend/agent_kv/management/commands/agent_kv_ttl_cleanup.py new file mode 100644 index 0000000000..c8025c40c9 --- /dev/null +++ b/backend/agent_kv/management/commands/agent_kv_ttl_cleanup.py @@ -0,0 +1,24 @@ +"""CronJob entrypoint for the Agent-KV TTL cleanup of expired staged files. + +Thin wrapper around :func:`agent_kv.maintenance.run_ttl_cleanup` -- see that +function's docstring for the cleanup logic itself (spec §5.4). This command +is the cloud mechanism for driving it: a Kubernetes CronJob runs +``python manage.py agent_kv_ttl_cleanup`` on a schedule. The internal +``TTLCleanupView``/``POST /internal/v1/agent-kv/ttl-cleanup/`` endpoint +(``agent_kv/internal_views.py``) delegates to the same function and remains +the OSS/self-hosted mechanism, driven by the PG-scheduler periodic task +(``workers/scheduler/agent_kv_tasks.py``). +""" + +import json + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Delete staged files for expired Agent-KV jobs (CronJob entrypoint)" + + def handle(self, *args, **options): + from agent_kv.maintenance import run_ttl_cleanup + + self.stdout.write(json.dumps(run_ttl_cleanup())) diff --git a/backend/agent_kv/migrations/0001_initial.py b/backend/agent_kv/migrations/0001_initial.py new file mode 100644 index 0000000000..0fa24c9eac --- /dev/null +++ b/backend/agent_kv/migrations/0001_initial.py @@ -0,0 +1,157 @@ +# Generated by Django 4.2.30 on 2026-08-28 12:16 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("account_v2", "0007_organization_restrict_connector_creation"), + ] + + operations = [ + migrations.CreateModel( + name="AgentKVKey", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=128)), + ("description", models.CharField(blank=True, default="", max_length=512)), + ("key", models.UUIDField(default=uuid.uuid4, unique=True)), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="agent_kv_keys_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organization", + models.ForeignKey( + blank=True, + db_comment="Foreign key reference to the Organization model.", + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="account_v2.organization", + ), + ), + ], + options={ + "db_table": "agent_kv_key", + }, + ), + migrations.CreateModel( + name="AgentKVJob", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("task_id", models.UUIDField(blank=True, null=True)), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("DISPATCHED", "Dispatched"), + ("RUNNING", "Running"), + ("COMPLETED", "Completed"), + ("FAILED", "Failed"), + ("CANCELLED", "Cancelled"), + ], + default="PENDING", + max_length=16, + ), + ), + ("stage", models.CharField(blank=True, default="", max_length=32)), + ("stages", models.JSONField(blank=True, default=dict)), + ("pages_total", models.IntegerField(blank=True, null=True)), + ("input_ref", models.CharField(blank=True, default="", max_length=512)), + ("result_ref", models.CharField(blank=True, default="", max_length=512)), + ("usage_summary", models.JSONField(blank=True, null=True)), + ("error", models.TextField(blank=True, default="")), + ("dispatched_at", models.DateTimeField(blank=True, null=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ("tags", models.JSONField(blank=True, default=list)), + ("custom_data", models.JSONField(blank=True, null=True)), + ("webhook_url", models.URLField(blank=True, default="", max_length=1024)), + ( + "api_key", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="jobs", + to="agent_kv.agentkvkey", + ), + ), + ( + "organization", + models.ForeignKey( + blank=True, + db_comment="Foreign key reference to the Organization model.", + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="account_v2.organization", + ), + ), + ], + options={ + "db_table": "agent_kv_job", + }, + ), + migrations.AddConstraint( + model_name="agentkvkey", + constraint=models.UniqueConstraint( + fields=("name", "organization"), name="unique_agent_kv_key_name_per_org" + ), + ), + migrations.AddIndex( + model_name="agentkvjob", + index=models.Index( + fields=["organization", "status"], name="agent_kv_jo_organiz_9b6b97_idx" + ), + ), + migrations.AddIndex( + model_name="agentkvjob", + index=models.Index( + fields=["expires_at"], name="agent_kv_jo_expires_5966b4_idx" + ), + ), + ] diff --git a/backend/agent_kv/migrations/0002_agentkvjob_extractor.py b/backend/agent_kv/migrations/0002_agentkvjob_extractor.py new file mode 100644 index 0000000000..8889c7cd36 --- /dev/null +++ b/backend/agent_kv/migrations/0002_agentkvjob_extractor.py @@ -0,0 +1,21 @@ +"""Record which extractor each job ran. + +Defaulting to "kv" is the historical truth, not a guess: before this column +existed the API accepted exactly one extractor and it was always `kv`. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("agent_kv", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="agentkvjob", + name="extractor", + field=models.CharField(default="kv", max_length=32), + ), + ] diff --git a/backend/agent_kv/migrations/0003_agentkvjob_cleanup_failed_at.py b/backend/agent_kv/migrations/0003_agentkvjob_cleanup_failed_at.py new file mode 100644 index 0000000000..d67b837ffb --- /dev/null +++ b/backend/agent_kv/migrations/0003_agentkvjob_cleanup_failed_at.py @@ -0,0 +1,41 @@ +"""Record when TTL cleanup last failed on a job, and index the ordering. + +Review follow-up on UN-4044. ``run_ttl_cleanup`` retains a ref whose file +delete failed, because the ref is the only handle a retry can work from. Under +the previous plain ``expires_at`` ordering those rows refilled the capped batch +on every tick, so a persistent object-store fault on 500 rows stalled cleanup +outright and every later expired job kept its files past TTL. + +Ordering is now ``(cleanup_failed_at NULLS FIRST, expires_at)``: a job that has +never failed is always processed ahead of one that has, so failures cannot +block the backlog, and the stamp is refreshed on each failed attempt so +failures rotate rather than one row absorbing every retry. + +A plain (non-CONCURRENT) AddIndex is correct here, unlike the concurrent builds +used elsewhere in this codebase: ``agent_kv_job`` is created by this app's own +``0001_initial`` and has never been deployed, so the table is empty when this +runs and the ACCESS EXCLUSIVE lock has nothing to block. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("agent_kv", "0002_agentkvjob_extractor"), + ] + + operations = [ + migrations.AddField( + model_name="agentkvjob", + name="cleanup_failed_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddIndex( + model_name="agentkvjob", + index=models.Index( + fields=["cleanup_failed_at", "expires_at"], + name="agent_kv_jo_cleanup_f38edc_idx", + ), + ), + ] diff --git a/backend/agent_kv/migrations/__init__.py b/backend/agent_kv/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py new file mode 100644 index 0000000000..fa28b4d68e --- /dev/null +++ b/backend/agent_kv/models.py @@ -0,0 +1,142 @@ +import uuid + +from account_v2.models import User +from django.db import models +from django.utils import timezone +from utils.models.base_model import BaseModel +from utils.models.organization_mixin import DefaultOrganizationMixin + +from agent_kv.constants import V1_EXTRACTOR_NAME + + +class AgentKVKey(DefaultOrganizationMixin, BaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=128) + description = models.CharField(max_length=512, blank=True, default="") + key = models.UUIDField(default=uuid.uuid4, unique=True) + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="agent_kv_keys_created", + ) + modified_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="+", + ) + + class Meta: + db_table = "agent_kv_key" + constraints = [ + models.UniqueConstraint( + fields=["name", "organization"], + name="unique_agent_kv_key_name_per_org", + ), + ] + + def __str__(self): + return f"{self.name} ({self.organization})" + + +class JobStatus(models.TextChoices): + PENDING = "PENDING" + DISPATCHED = "DISPATCHED" + RUNNING = "RUNNING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + + +class AgentKVJob(DefaultOrganizationMixin, BaseModel): + TERMINAL = frozenset({JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}) + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + api_key = models.ForeignKey( + AgentKVKey, + on_delete=models.SET_NULL, + null=True, + related_name="jobs", + ) + task_id = models.UUIDField(null=True, blank=True) + # Which extractor this job ran. v1 dispatches exactly one per job, but + # WHICH one is now a choice, and status/result key their payloads by it -- + # without this column a table job's output would be filed under `kv`. + extractor = models.CharField(max_length=32, default=V1_EXTRACTOR_NAME) + status = models.CharField( + max_length=16, + choices=JobStatus.choices, + default=JobStatus.PENDING, + ) + stage = models.CharField(max_length=32, blank=True, default="") + stages = models.JSONField(default=dict, blank=True) + pages_total = models.IntegerField(null=True, blank=True) + input_ref = models.CharField(max_length=512, blank=True, default="") + result_ref = models.CharField(max_length=512, blank=True, default="") + usage_summary = models.JSONField(null=True, blank=True) + error = models.TextField(blank=True, default="") + dispatched_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + # When TTL cleanup last failed to delete one of this job's files. NULL means + # "never attempted, or last attempt succeeded", and it is what separates + # the two lanes run_ttl_cleanup processes: NOT NULL rows are retries, which + # get a reserved slice of each batch, and NULL rows are new expirations, + # which get the rest. Capping both is what stops either side starving the + # other. See run_ttl_cleanup. + cleanup_failed_at = models.DateTimeField(null=True, blank=True) + tags = models.JSONField(default=list, blank=True) + custom_data = models.JSONField(null=True, blank=True) + webhook_url = models.URLField(max_length=1024, blank=True, default="") + + class Meta: + db_table = "agent_kv_job" + indexes = [ + models.Index(fields=["organization", "status"]), + models.Index(fields=["expires_at"]), + # Serves BOTH of run_ttl_cleanup's lanes, each of which sorts by a + # single column ascending behind a predicate on this index's + # leading column: + # retries: WHERE cleanup_failed_at IS NOT NULL ORDER BY cleanup_failed_at + # fresh: WHERE cleanup_failed_at IS NULL ORDER BY expires_at + # Deliberately a plain ascending index. The first version of that + # query asked for `cleanup_failed_at ASC NULLS FIRST`, which a + # btree index cannot serve (btree is NULLS LAST ascending), so + # Postgres sorted every matching expired row before applying the + # 500-row limit -- work that grew with the backlog. Splitting the + # query removed the NULLS FIRST rather than adding a second index + # with a non-default null order. + models.Index(fields=["cleanup_failed_at", "expires_at"]), + ] + + @classmethod + def mark_terminal( + cls, + job_id, + organization_id, + new_status, + *, + error="", + result_ref="", + usage_summary=None, + ) -> bool: + """The ONLY way to reach a terminal state (spec §5.4 write guard). + + Guarded UPDATE: at-least-once callbacks, cancel, and the sweep can all + race; whoever lands first wins and everyone else no-ops. + """ + fields = {"status": new_status, "completed_at": timezone.now()} + if error: + fields["error"] = error + if result_ref: + fields["result_ref"] = result_ref + if usage_summary is not None: + fields["usage_summary"] = usage_summary + updated = ( + cls.objects.filter(id=job_id, organization_id=organization_id) + .exclude(status__in=list(cls.TERMINAL)) + .update(**fields) + ) + return updated == 1 diff --git a/backend/agent_kv/permissions.py b/backend/agent_kv/permissions.py new file mode 100644 index 0000000000..d9ce287517 --- /dev/null +++ b/backend/agent_kv/permissions.py @@ -0,0 +1,8 @@ +from platform_api.permissions import IsOrganizationAdmin as PlatformIsOrganizationAdmin + + +class IsOrganizationAdmin(PlatformIsOrganizationAdmin): + message = "Only organization admins can manage Agent-KV API keys." + + +__all__ = ["IsOrganizationAdmin"] diff --git a/backend/agent_kv/rate_limiter.py b/backend/agent_kv/rate_limiter.py new file mode 100644 index 0000000000..85796ef5d2 --- /dev/null +++ b/backend/agent_kv/rate_limiter.py @@ -0,0 +1,109 @@ +import logging +import time + +from django.conf import settings +from django_redis import get_redis_connection + +logger = logging.getLogger(__name__) + + +def _limiter_failure_allows_request() -> bool: + """Whether a request proceeds when the limiter backend is unreachable. + + Fails CLOSED unless `AGENT_KV_LIMITER_FAIL_OPEN` is explicitly set. Both + limiters previously returned True on any Redis exception, which is the one + choice that cannot be observed from the outside: a Sentinel failover + silently removed the concurrency ceiling AND the per-key rate ceiling at the + same time, and the API went on accepting billable LLM work as if both still + held. Closed is the safer default for a paid, concurrency-capped API -- a + 429 is recoverable by the caller, an unbounded fan-out is not. + + The waiver remains available because availability-over-accounting is a + legitimate operational choice, but it now has to be made deliberately, in + config, where it is visible -- rather than being the implicit behaviour of + an `except` block. + """ + return bool(getattr(settings, "AGENT_KV_LIMITER_FAIL_OPEN", False)) + + +_SLOT_TTL_SECONDS = 6 * 3600 + + +def _redis(): + # Same handle acquisition as api_v2.rate_limiter (`redis_cache = + # get_redis_connection("default")`); api_v2 has no reusable helper to + # call into, so the construction line is copied here rather than + # modifying api_v2. + return get_redis_connection("default") + + +class AgentKVConcurrencyLimiter: + @staticmethod + def _key(organization_id: str) -> str: + return f"agent_kv:inflight:{organization_id}" + + # Trim stale slots, count, and claim a slot in ONE atomic server-side + # step. A client-side ``ZCARD`` followed by ``ZADD`` is a check-then-act + # race: N simultaneous submits all observe ``count < limit`` and all get + # accepted (caught live in the Task 13b run: 6 concurrent submits against + # a limit of 5 produced six 202s). Redis runs a script atomically, so at + # most ``limit`` members can ever be added. + _ACQUIRE_SCRIPT = """ +local key = KEYS[1] +redis.call('ZREMRANGEBYSCORE', key, 0, ARGV[3]) +if redis.call('ZCARD', key) >= tonumber(ARGV[4]) then + return 0 +end +redis.call('ZADD', key, ARGV[2], ARGV[1]) +redis.call('EXPIRE', key, ARGV[5]) +return 1 +""" + + @classmethod + def check_and_acquire(cls, organization_id: str, job_id: str) -> bool: + try: + r = _redis() + now = time.time() + key = cls._key(organization_id) + acquired = r.eval( + cls._ACQUIRE_SCRIPT, + 1, + key, + job_id, + now, + now - _SLOT_TTL_SECONDS, + settings.AGENT_KV_CONCURRENT_LIMIT, + _SLOT_TTL_SECONDS, + ) + return bool(int(acquired)) + except Exception: + # Fail CLOSED by default. Both this ceiling and the per-key rate + # ceiling used to `return True` on any Redis error, so a single + # Sentinel failover or pool exhaustion removed BOTH at once while + # the API kept returning 202s for billable LLM work -- with nothing + # but a per-request `logger.warning` to show for it. + # + # `logger.exception` (not warning): the limiter being unavailable is + # an error, and the old level is what let this sit unnoticed. + logger.exception("agent-kv concurrency limiter unavailable") + return _limiter_failure_allows_request() + + @classmethod + def release(cls, organization_id: str, job_id: str) -> None: + try: + _redis().zrem(cls._key(organization_id), job_id) + except Exception: + logger.warning("agent-kv slot release failed", exc_info=True) + + +def check_key_rate(key_id: str) -> bool: + try: + r = _redis() + window = int(time.time() // 60) + key = f"agent_kv:rate:{key_id}:{window}" + count = r.incr(key) + r.expire(key, 120) + return count <= settings.AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE + except Exception: + logger.exception("agent-kv key rate limiter unavailable") + return _limiter_failure_allows_request() diff --git a/backend/agent_kv/serializers.py b/backend/agent_kv/serializers.py new file mode 100644 index 0000000000..081aeabb6b --- /dev/null +++ b/backend/agent_kv/serializers.py @@ -0,0 +1,15 @@ +from rest_framework import serializers + +from agent_kv.models import AgentKVKey + + +class AgentKVKeySerializer(serializers.ModelSerializer): + class Meta: + model = AgentKVKey + fields = ["id", "name", "description", "key", "is_active", "created_at"] + + +class AgentKVKeyWriteSerializer(serializers.ModelSerializer): + class Meta: + model = AgentKVKey + fields = ["name", "description", "is_active"] diff --git a/backend/agent_kv/storage.py b/backend/agent_kv/storage.py new file mode 100644 index 0000000000..b19adce545 --- /dev/null +++ b/backend/agent_kv/storage.py @@ -0,0 +1,165 @@ +"""Object-store staging/results for agent-kv (spec §5.4, §6.4). + +Paths are the contract: {AGENT_KV_STORAGE_DIR_PREFIX}/{org_id}/{job_id}/input{ext} +and .../result.json. No document bytes ever ride the broker. + +The prefix is bucket-rooted (default ``unstract/agent_kv``) like +``WORKFLOW_EXECUTION_DIR_PREFIX``/``API_EXECUTION_DIR_PREFIX``: s3fs/gcsfs read +the first path segment as the *bucket*, so a bucket-less root (the old +``org/{org_id}/...``) makes every write fail with ``NoSuchBucket``. The cloud +executor keys its OCR cache under the same root +(``{prefix}/{org_id}/cache/...``), so both sides must be configured alike. +""" + +import logging +import os +import uuid + +from django.conf import settings + +from unstract.filesystem import FileStorageType, FileSystem + +logger = logging.getLogger(__name__) + + +def _fs(): + return FileSystem(FileStorageType.AGENT_KV).get_file_storage() + + +def _base(org_id: str, job_id: str) -> str: + return f"{settings.AGENT_KV_STORAGE_DIR_PREFIX}/{org_id}/{job_id}" + + +def stage_input(org_id: str, job_id: str, uploaded_file) -> str: + ext = os.path.splitext(uploaded_file.name or "")[1].lower() or ".bin" + ref = f"{_base(org_id, job_id)}/input{ext}" + data = b"".join(uploaded_file.chunks()) + _fs().write(path=ref, mode="wb", data=data) + return ref + + +def write_result( + org_id: str, job_id: str, result: dict, *, nonce: str | None = None +) -> str: + """Write a job result and return its object-store ref. + + The path is UNIQUE per call (``.../result-.json``, nonce defaulting + to a fresh uuid4 hex) rather than a deterministic ``.../result.json``. + ``FinalizeView`` writes the result BEFORE it attempts the terminal-state + guard; on a concurrent duplicate-SUCCESS finalize, the guard loser must + clean up ONLY the orphan it just wrote -- with a shared deterministic path + it would instead delete the exact file the winning row's ``result_ref`` + points at, 500-ing the completed job's result endpoint / losing data + (pre-Greptile critical #3). A unique ref per attempt makes the loser's + ``delete_result_file`` target its own file and nothing else. + + Backward-tolerant: ``read_result``/``delete_job_files``/TTL cleanup all use + the ref STORED on the job row, so any previously written ``result.json`` + ref remains readable and removable unchanged. + """ + ref = f"{_base(org_id, job_id)}/result-{nonce or uuid.uuid4().hex}.json" + _fs().json_dump(path=ref, data=result) + return ref + + +def read_result(result_ref: str) -> dict: + return _fs().json_load(path=result_ref) + + +def delete_job_files(job) -> list[str]: + """Delete the job's staged input and result, returning the ref FIELD names + that are now confirmed to point at nothing. + + The return value exists so a caller can blank only the refs whose files are + actually gone. Blanking unconditionally -- what both callers used to do -- + loses the only handle to a file whose delete FAILED: TTL cleanup selects + candidates by `input_ref > "" OR result_ref > ""`, so a blanked row drops + out of the candidate set permanently and the object sits in the bucket + forever, unreferenced and unbillable-to-anyone (Greptile review #3). + + A ref is reported clear when it was already empty (nothing was ever staged) + or when the delete succeeded. ``FileNotFoundError`` counts as success: the + goal is "the file is not there", and a ref whose object is already gone has + nothing left to retry. Any other exception leaves the ref OUT of the + returned list, so the caller keeps it and the next pass retries. + """ + fh = _fs() + cleared: list[str] = [] + for field in ("input_ref", "result_ref"): + ref = getattr(job, field, "") or "" + if not ref: + cleared.append(field) + continue + try: + fh.rm(path=ref) + except FileNotFoundError: + cleared.append(field) + except Exception: + # Left out of `cleared` deliberately -- the ref is the retry handle. + logger.warning( + "agent-kv cleanup: could not remove %s (ref kept for retry)", + ref, + exc_info=True, + ) + else: + cleared.append(field) + return cleared + + +def delete_result_file(result_ref: str) -> None: + """Best-effort delete of a result file that was just written but never + got a ref persisted onto the job row. + + ``FinalizeView`` writes the result *before* attempting the terminal- + state guard (spec §5.4); if the guard loses the race -- a concurrent + cancel or a duplicate finalize won instead -- the file this call just + wrote is orphaned: nothing anywhere points at it, so it would otherwise + sit in object storage forever, past even TTL cleanup (which only acts on + a job's *stored* ``result_ref``). Tolerant of the file being missing + already, mirroring ``delete_input``/``delete_job_files``. + """ + if not result_ref: + return + try: + _fs().rm(path=result_ref) + except Exception: + logger.warning("agent-kv cleanup: could not remove orphaned %s", result_ref) + + +def delete_input(job) -> bool: + """Delete only the staged input file (spec D10: "uploaded document + deleted on job completion"), returning whether ``input_ref`` is now + confirmed to point at nothing. + + Deliberately narrower than ``delete_job_files``: it never touches + ``result_ref``/the result file. ``FinalizeView`` calls this right after + a job is fully terminalized, in the same request that may have just + written the result -- that file must be left completely alone. + + Same confirmed-clear contract as ``delete_job_files``, and for the same + reason: this swallowed every exception while its caller blanked + ``input_ref`` unconditionally, so a transient object-store error orphaned + the customer's uploaded document permanently -- TTL cleanup only selects + rows that still carry a non-blank ref, so a blanked row can never be + reconsidered. ``delete_job_files`` and its two callers were fixed for this + in the Greptile round; THIS third site was missed, because the fix was + applied by following the one function's callers rather than by grepping for + every site that blanks a ref. + + True when there was nothing to delete or the file is gone (including + already-missing); False when it is still there and the ref must be kept. + """ + if not job.input_ref: + return True + try: + _fs().rm(path=job.input_ref) + except FileNotFoundError: + return True + except Exception: + logger.warning( + "agent-kv cleanup: could not remove %s (ref kept for retry)", + job.input_ref, + exc_info=True, + ) + return False + return True diff --git a/backend/agent_kv/tests/__init__.py b/backend/agent_kv/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/agent_kv/tests/_factories.py b/backend/agent_kv/tests/_factories.py new file mode 100644 index 0000000000..2596355e8f --- /dev/null +++ b/backend/agent_kv/tests/_factories.py @@ -0,0 +1,26 @@ +"""Shared test object factories for the Agent-KV suites.""" + +from account_v2.models import Organization + +from agent_kv.models import AgentKVKey + + +def kv_key(*, slug: str = "acme-slug", org_pk: int = 1, **overrides) -> AgentKVKey: + """A USABLE AgentKVKey — one that carries an organization. + + `AgentKVKey.organization` is nullable on the model, but a key without one is + not usable: every downstream consumer is org-scoped (the subscription gate, + the concurrency limiter, the storage prefix, every job lookup), so + `AgentKVKeyValidator` refuses it. Tests that build a bare + `AgentKVKey(name=...)` are therefore constructing a key the auth layer would + reject, and any behaviour they assert past that point is unreachable in + production. + + `org_pk` and `slug` are deliberately DIFFERENT kinds of value: + `Subscription.organization_id` is a CharField holding the slug, while the + key's own `organization_id` is the FK primary key. Keeping them distinct is + what lets a test catch code that reaches for the wrong one. + """ + key = AgentKVKey(name="k", is_active=True, **overrides) + key.organization = Organization(id=org_pk, organization_id=slug) + return key diff --git a/backend/agent_kv/tests/fixtures/two_page.pdf b/backend/agent_kv/tests/fixtures/two_page.pdf new file mode 100644 index 0000000000000000000000000000000000000000..92ee5755ea654f06f7fc0997be78496215d5dea3 GIT binary patch literal 551 zcmcJM!Ab)$5Qgvb6mwb7gH5t)fl*}ebnhQ9odV0fGu_&HylD1~DKB9*16vz RUNNING on +# the executor's first stage report, which can land before this post-enqueue +# bookkeeping. The PENDING-guarded UPDATE then matches 0 rows and +# `dispatched_at` stays NULL -- and a non-terminal row with a NULL +# `dispatched_at` is invisible to BOTH sweep phases (phase 1 requires PENDING; +# phase 2's `dispatched_at__lt` can never match a NULL), so the job reports +# `running` forever and `GET result` 409s for the life of the row. +@mock.patch.object(dispatch, "_platform_api_key", return_value="pk") +@mock.patch.object(dispatch, "_dispatcher") +@mock.patch.object(AgentKVJob, "objects") +def test_dispatch_stamps_dispatched_at_when_the_row_already_moved_to_running( + m_objects, m_disp, m_key +): + m_objects.filter.return_value.update.return_value = 0 # PENDING guard missed + job = _job() + + dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) + + # Second write is narrowed to a row that still has no dispatch time, and + # excludes terminal rows. + assert m_objects.filter.call_args_list[1].kwargs == { + "id": job.id, + "dispatched_at__isnull": True, + } + fallback = m_objects.filter.return_value.exclude.return_value + update_kwargs = fallback.update.call_args.kwargs + assert update_kwargs["dispatched_at"] == job.dispatched_at + assert update_kwargs["task_id"] == job.task_id + # Crucially does NOT write `status`: the row genuinely was dispatched, but + # moving RUNNING back to DISPATCHED would discard the executor's progress. + assert "status" not in update_kwargs + + +@mock.patch.object(dispatch, "_platform_api_key", return_value="pk") +@mock.patch.object(dispatch, "_dispatcher") +@mock.patch.object(AgentKVJob, "objects") +def test_dispatch_does_not_attempt_the_fallback_when_the_pending_guard_won( + m_objects, m_disp, m_key +): + m_objects.filter.return_value.update.return_value = 1 # normal path + job = _job() + + dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) + + assert len(m_objects.filter.call_args_list) == 1 + assert not m_objects.filter.return_value.exclude.called + + +@mock.patch.object(dispatch, "_platform_api_key", return_value="pk") +@mock.patch.object(dispatch, "_dispatcher") +def test_enqueue_failure_raises_dispatch_error(m_disp, m_key): + m_disp.return_value.dispatch_with_callback.side_effect = RuntimeError("broker down") + job = _job() + with pytest.raises(dispatch.DispatchError): + dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) + + +@mock.patch.object(dispatch, "_dispatcher") +def test_dispatch_job_uses_platform_api_key_lookup(m_disp): + from platform_settings_v2.platform_auth_service import ( + PlatformAuthenticationService, + ) + + with mock.patch.object( + PlatformAuthenticationService, + "get_active_platform_key", + return_value=mock.Mock(key="the-real-key"), + ): + job = _job() + with mock.patch.object(AgentKVJob, "objects"): + dispatch.dispatch_job( + job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={} + ) + ctx = m_disp.return_value.dispatch_with_callback.call_args.args[0] + assert ctx.executor_params["platform_api_key"] == "the-real-key" + # The lookup takes the org's public slug, never the row PK (13b F6). + PlatformAuthenticationService.get_active_platform_key.assert_called_once_with( + "org_slug_1" + ) + assert ctx.organization_id == "7" + + +def test_platform_api_key_raises_dispatch_error_when_absent(): + from platform_settings_v2.platform_auth_service import ( + PlatformAuthenticationService, + ) + + with mock.patch.object( + PlatformAuthenticationService, + "get_active_platform_key", + return_value=None, + ): + job = _job() + with pytest.raises(dispatch.DispatchError): + dispatch._platform_api_key(job) + + +@mock.patch.object(dispatch, "_dispatcher") +@mock.patch.object(dispatch, "_platform_api_key") +def test_raw_exception_from_platform_key_lookup_is_wrapped_as_dispatch_error( + m_key, m_disp +): + """Regression: platform-key lookup and context construction must live + inside dispatch_job's try — a raw (non-DispatchError) exception there + (e.g. a transient DB error) must not escape uncaught. + """ + m_key.side_effect = RuntimeError("platform db down") + + job = _job() + with pytest.raises(dispatch.DispatchError): + dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) + + # Never got far enough to enqueue. + assert not m_disp.return_value.dispatch_with_callback.called + + +def test_dispatcher_factory_call_matches_the_live_signature(): + """Guard the OSS seam that a platform change can silently move. + + UN-4046 removed `get_executor_dispatcher(celery_app=...)`; our call site + kept passing it, so every submit raised TypeError inside dispatch_job and + failed the job. Nothing caught it -- the mismatch is invisible to a mocked + dispatcher and only appears when a real request is made. + + Binding our actual call against the real signature fails loudly the next + time that function's parameters change. + """ + from unittest import mock # noqa: PLC0415 + + from agent_kv import dispatch as d # noqa: PLC0415 + + # autospec=True makes the stub enforce the REAL function's signature, so + # this asserts our call site against it rather than against a permissive + # Mock. Re-adding `celery_app=` here raises TypeError, exactly as production + # did. + with mock.patch( + "pg_queue.executor_rpc.get_executor_dispatcher", autospec=True + ) as m_factory: + d._dispatcher() + m_factory.assert_called_once_with() + + +# Post-enqueue bookkeeping must never fail a dispatch that already succeeded. +# `SubmitView` turns a DispatchError into a FAILED job, so a DB hiccup here +# would terminalize a job whose task is on the queue -- the executor then runs, +# calls back, and finds a terminal row it cannot write to, while the caller was +# told nothing was billed for work that did run. The review round added a +# SECOND update after the enqueue, which widened this window. +@mock.patch.object(dispatch, "_platform_api_key", return_value="pk") +@mock.patch.object(dispatch, "_dispatcher") +@mock.patch.object(AgentKVJob, "objects") +def test_bookkeeping_failure_does_not_fail_an_already_queued_dispatch( + m_objects, m_disp, m_key +): + m_objects.filter.side_effect = OSError("db gone") + job = _job() + + # Must not raise: raising is what would mark the live job FAILED. + dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) + + # And the enqueue did happen, so the task is genuinely on the queue. + assert m_disp.return_value.dispatch_with_callback.called + + +@mock.patch.object(dispatch, "_platform_api_key", return_value="pk") +@mock.patch.object(dispatch, "_dispatcher") +@mock.patch.object(AgentKVJob, "objects") +def test_bookkeeping_failure_is_logged_at_error_level(m_objects, m_disp, m_key, caplog): + """Swallowing it silently would trade one bad failure mode for another: + the sweep reconciles the row, but nothing would say why it had to. + """ + m_objects.filter.side_effect = OSError("db gone") + with caplog.at_level("ERROR", logger=dispatch.logger.name): + dispatch.dispatch_job( + _job(), extractor=TABLE_EXTRACTOR_NAME, schema={}, options={} + ) + assert any(r.levelname == "ERROR" and r.exc_info for r in caplog.records) diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py new file mode 100644 index 0000000000..c0c4fbf9ae --- /dev/null +++ b/backend/agent_kv/tests/test_internal_views.py @@ -0,0 +1,686 @@ +import os +import uuid +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from rest_framework.test import APIRequestFactory # noqa: E402 + +from agent_kv import internal_views as iv # noqa: E402 +from agent_kv import storage # noqa: E402 +from agent_kv.models import AgentKVJob, JobStatus # noqa: E402 + + +def _post(path, body): + return APIRequestFactory().post(path, body, format="json") + + +def _merge_payload(update_call): + """Unwrap a ``stages=`` jsonb-merge expression's {stage: entry} payload. + + Also pins the expression's shape: a ``||`` (jsonb concatenation) + ``CombinedExpression`` over the ``stages`` column, not a plain dict -- + the whole point of the DB-side merge is that ``.update()`` never + receives a Python-computed full-column dict. + """ + expr = update_call.kwargs["stages"] + assert not isinstance(expr, dict), "stages must be a jsonb-merge expression" + assert expr.connector == "||" + assert expr.lhs.name == "stages" + return expr.rhs.value + + +# --------------------------------------------------------------------------- +# _stage_merge_expression / _sanitize_counters (expression-builder unit tests) +# --------------------------------------------------------------------------- + + +# (u1) the expression builder produces a single-entry {stage: entry} jsonb +# merge payload over the `stages` column via the `||` connector. +def test_stage_merge_expression_builds_single_entry_jsonb_merge(): + expr = iv._stage_merge_expression("extraction", {"status": "running"}) + + assert expr.connector == "||" + assert expr.lhs.name == "stages" + assert expr.rhs.value == {"extraction": {"status": "running"}} + + +# (u2) counters colliding with reserved keys are dropped. +def test_sanitize_counters_drops_reserved_keys(): + assert iv._sanitize_counters({"status": "done", "seconds": 999, "pages": 3}) == { + "pages": 3 + } + + +# (u3) non-scalar counter values (dict/list) are dropped; scalars pass through. +def test_sanitize_counters_drops_non_scalar_values(): + assert iv._sanitize_counters( + {"pages": 3, "nested": {"a": 1}, "arr": [1, 2], "ok": "yes", "flag": True} + ) == {"pages": 3, "ok": "yes", "flag": True} + + +# (u4) a non-dict counters payload (e.g. a string or list) sanitizes to {}. +def test_sanitize_counters_non_dict_input_is_empty(): + assert iv._sanitize_counters("not-a-dict") == {} + assert iv._sanitize_counters(None) == {} + + +# --------------------------------------------------------------------------- +# StageReportView +# --------------------------------------------------------------------------- + + +# (1) org_id missing from body -> 400 (ambient-auth views require it in body). +def test_stage_report_missing_org_id_is_400(): + resp = iv.StageReportView.as_view()( + _post("/x", {"stage": "extraction", "status": "running"}), + job_id=uuid.uuid4(), + ) + assert resp.status_code == 400 + + +# (1b) missing `stage` -> 400. +def test_stage_report_missing_stage_is_400(): + resp = iv.StageReportView.as_view()( + _post("/x", {"org_id": "org1", "status": "running"}), + job_id=uuid.uuid4(), + ) + assert resp.status_code == 400 + + +# (1c) missing/invalid `status` -> 400 (must be exactly "running" or "done"). +def test_stage_report_invalid_status_is_400(): + resp = iv.StageReportView.as_view()( + _post("/x", {"org_id": "org1", "stage": "extraction", "status": "bogus"}), + job_id=uuid.uuid4(), + ) + assert resp.status_code == 400 + + resp_missing = iv.StageReportView.as_view()( + _post("/x", {"org_id": "org1", "stage": "extraction"}), + job_id=uuid.uuid4(), + ) + assert resp_missing.status_code == 400 + + +# (2) first stage report on a PENDING job flips it to RUNNING exactly once, +# and merges the stage entry via a DB-side jsonb `||` expression (not a +# plain dict) targeting only the reported stage's key. +@mock.patch.object(AgentKVJob, "objects") +def test_stage_report_flips_pending_to_running_once(m_jobs): + job = AgentKVJob(status=JobStatus.PENDING, stages={}) + qs = m_jobs.filter.return_value.exclude.return_value + qs.first.return_value = job + + resp = iv.StageReportView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "stage": "extraction", + "status": "running", + }, + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + qs.update.assert_called_once() + call = qs.update.call_args + assert _merge_payload(call) == {"extraction": {"status": "running"}} + assert call.kwargs["stage"] == "extraction" + assert call.kwargs["status"] == JobStatus.RUNNING + + +# (2b) a stage report on an already-RUNNING job does NOT re-flip status -- +# the "once" half of "flips RUNNING once". +@mock.patch.object(AgentKVJob, "objects") +def test_stage_report_on_running_job_does_not_touch_status(m_jobs): + job = AgentKVJob( + status=JobStatus.RUNNING, stages={"extraction": {"status": "running"}} + ) + qs = m_jobs.filter.return_value.exclude.return_value + qs.first.return_value = job + + resp = iv.StageReportView.as_view()( + _post( + "/x", + {"org_id": "org1", "stage": "extraction", "status": "done", "seconds": 1.5}, + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + call = qs.update.call_args + assert "status" not in call.kwargs + assert _merge_payload(call) == {"extraction": {"status": "done", "seconds": 1.5}} + + +# (3) stage report on a CANCELLED (terminal) job -- the update queryset +# excludes TERMINAL, so .first() returns None -- is a 200 no-op, never +# reaching .update(). +@mock.patch.object(AgentKVJob, "objects") +def test_stage_report_on_cancelled_job_is_200_noop(m_jobs): + qs = m_jobs.filter.return_value.exclude.return_value + qs.first.return_value = None + + resp = iv.StageReportView.as_view()( + _post("/x", {"org_id": "org1", "stage": "extraction", "status": "running"}), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + assert not qs.update.called + + +# (4) duplicate stage report (same stage name posted twice) overwrites the +# entry rather than appending. With the DB-side jsonb `||` merge this falls +# out structurally: the merge payload always carries exactly one key (the +# reported stage), so the jsonb `||` operator replaces that key's value on +# the second call instead of stacking a second version alongside it. +@mock.patch.object(AgentKVJob, "objects") +def test_stage_report_duplicate_overwrites_not_appends(m_jobs): + job = AgentKVJob( + status=JobStatus.RUNNING, + stages={"extraction": {"status": "running"}}, + ) + qs = m_jobs.filter.return_value.exclude.return_value + qs.first.return_value = job + + resp = iv.StageReportView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "stage": "extraction", + "status": "done", + "seconds": 2.0, + "counters": {"pages": 3}, + }, + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + call = qs.update.call_args + payload = _merge_payload(call) + assert list(payload.keys()) == ["extraction"] + assert payload == {"extraction": {"status": "done", "seconds": 2.0, "pages": 3}} + assert call.kwargs["stage"] == "extraction" + + +# (5) the stage-report endpoint is the write gate for job.stages -- only the +# defined shape (status, optional seconds, sanitized flat counters) is ever +# persisted; unexpected top-level body keys (e.g. a stray "evil") must not +# leak into the stored entry. +@mock.patch.object(AgentKVJob, "objects") +def test_stage_report_ignores_unexpected_top_level_keys(m_jobs): + job = AgentKVJob(status=JobStatus.RUNNING, stages={}) + qs = m_jobs.filter.return_value.exclude.return_value + qs.first.return_value = job + + resp = iv.StageReportView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "stage": "extraction", + "status": "running", + "evil": "payload", + "another_bogus_key": 123, + }, + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + stored_entry = _merge_payload(qs.update.call_args)["extraction"] + assert stored_entry == {"status": "running"} + + +# (5b) a counter named "status" or "seconds" cannot clobber the endpoint's +# own reserved fields. +@mock.patch.object(AgentKVJob, "objects") +def test_stage_report_counters_cannot_override_reserved_keys(m_jobs): + job = AgentKVJob(status=JobStatus.RUNNING, stages={}) + qs = m_jobs.filter.return_value.exclude.return_value + qs.first.return_value = job + + resp = iv.StageReportView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "stage": "extraction", + "status": "running", + "counters": {"status": "done", "seconds": 999, "pages": 3}, + }, + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + stored_entry = _merge_payload(qs.update.call_args)["extraction"] + assert stored_entry == {"status": "running", "pages": 3} + + +# (5c) a nested-dict (or list) counter value is dropped rather than stored. +@mock.patch.object(AgentKVJob, "objects") +def test_stage_report_counters_drops_nested_values(m_jobs): + job = AgentKVJob(status=JobStatus.RUNNING, stages={}) + qs = m_jobs.filter.return_value.exclude.return_value + qs.first.return_value = job + + resp = iv.StageReportView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "stage": "extraction", + "status": "running", + "counters": {"pages": 3, "nested": {"a": 1}, "arr": [1, 2]}, + }, + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + stored_entry = _merge_payload(qs.update.call_args)["extraction"] + assert stored_entry == {"status": "running", "pages": 3} + + +# --------------------------------------------------------------------------- +# FinalizeView +# --------------------------------------------------------------------------- + + +# (6) org_id missing from body -> 400. +def test_finalize_missing_org_id_is_400(): + resp = iv.FinalizeView.as_view()(_post("/x", {"success": True}), job_id=uuid.uuid4()) + assert resp.status_code == 400 + + +# (6b) missing `success` -> 400, and no slot is released (nothing was +# finalized). +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +def test_finalize_missing_success_is_400(m_release): + resp = iv.FinalizeView.as_view()(_post("/x", {"org_id": "org1"}), job_id=uuid.uuid4()) + assert resp.status_code == 400 + assert not m_release.called + + +# (6c) a non-bool `success` (e.g. a truthy string) -> 400 rather than +# silently falling through to the failure branch and persisting a FAILED +# job with an empty error. +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +def test_finalize_non_bool_success_is_400(m_release, m_mark_terminal): + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": "true"}), job_id=uuid.uuid4() + ) + assert resp.status_code == 400 + assert not m_release.called + assert not m_mark_terminal.called + + +# (7) finalize success: writes the result THEN marks the job terminal (order +# matters -- a duplicate must never rewrite a result nobody asked to change). +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_success_writes_result_then_marks_terminal( + m_jobs, m_write, m_mark_terminal, m_release +): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + m_write.return_value = "org/o/agent_kv/j/result.json" + m_mark_terminal.return_value = True + + manager = mock.Mock() + manager.attach_mock(m_write, "write_result") + manager.attach_mock(m_mark_terminal, "mark_terminal") + + job_id = uuid.uuid4() + resp = iv.FinalizeView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "success": True, + "result": {"foo": "bar"}, + "usage_summary": {"tokens": 10}, + }, + ), + job_id=job_id, + ) + + assert resp.status_code == 200 + assert resp.data == { + "finalized": True, + "webhook_url": "https://example.com/hook", + "status": "completed", + } + assert [c[0] for c in manager.mock_calls] == ["write_result", "mark_terminal"] + m_write.assert_called_once_with("org1", str(job_id), {"foo": "bar"}) + m_mark_terminal.assert_called_once_with( + job_id, + "org1", + JobStatus.COMPLETED, + result_ref="org/o/agent_kv/j/result.json", + usage_summary={"tokens": 10}, + ) + + +# (7b) finalize success deletes the staged input and blanks input_ref (spec +# D10: "uploaded document deleted on job completion") -- the result file +# itself is untouched by this. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(iv, "delete_input") +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_success_deletes_input_and_blanks_ref( + m_jobs, m_write, m_delete_input, m_mark_terminal, m_release +): + job = AgentKVJob( + status=JobStatus.RUNNING, + webhook_url="", + input_ref="org/o/agent_kv/j/input.pdf", + ) + m_jobs.filter.return_value.first.return_value = job + m_write.return_value = "org/o/agent_kv/j/result.json" + + job_id = uuid.uuid4() + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {}}), + job_id=job_id, + ) + + assert resp.status_code == 200 + m_delete_input.assert_called_once_with(job) + m_jobs.filter.return_value.update.assert_called_once_with(input_ref="") + + +# (7c) success finalize whose guard is LOST right after the write (e.g. a +# cancel raced in between the read and mark_terminal's guarded UPDATE) -- +# the just-written result file has no ref anywhere pointing at it, so it's +# cleaned up immediately rather than orphaned forever. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_success_guard_loss_deletes_orphaned_result( + m_jobs, m_write, m_mark_terminal, m_delete_result, m_release +): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") + m_jobs.filter.return_value.first.return_value = job + m_write.return_value = "org/o/agent_kv/j/result.json" + + job_id = uuid.uuid4() + resp = iv.FinalizeView.as_view()( + _post( + "/x", + {"org_id": "org1", "success": True, "result": {"foo": "bar"}}, + ), + job_id=job_id, + ) + + assert resp.status_code == 200 + assert resp.data["finalized"] is False + m_delete_result.assert_called_once_with("org/o/agent_kv/j/result.json") + + +# (7d) success finalize that WINS the guard never deletes the result it just +# wrote -- only a guard-LOSS orphan triggers cleanup. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_success_guard_win_does_not_delete_result( + m_jobs, m_write, m_mark_terminal, m_delete_result, m_release +): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") + m_jobs.filter.return_value.first.return_value = job + m_write.return_value = "org/o/agent_kv/j/result.json" + + iv.FinalizeView.as_view()( + _post( + "/x", + {"org_id": "org1", "success": True, "result": {"foo": "bar"}}, + ), + job_id=uuid.uuid4(), + ) + + assert not m_delete_result.called + + +# (7e) concurrent duplicate-SUCCESS finalize race, exercised through the REAL +# storage layer (only the object-store FileSystem is faked): two finalize +# attempts land for one job, the first WINS the terminal guard and the second +# LOSES. Because write_result now returns a UNIQUE ref per attempt, the loser +# cleans up only ITS OWN orphaned result file -- the winner's result_ref +# target is left intact (pre-Greptile critical #3: the old deterministic +# result.json path let the loser delete the winner's live result). +@mock.patch.object(storage, "FileSystem") +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", side_effect=[True, False]) +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_duplicate_success_race_loser_deletes_only_its_own_result( + m_jobs, m_mark, m_release, m_fs +): + # Two separate reads that both observed the job as still RUNNING (the race + # window before either guarded UPDATE ran). + winner_job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="", input_ref="") + loser_job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="", input_ref="") + m_jobs.filter.return_value.first.side_effect = [winner_job, loser_job] + + fh = m_fs.return_value.get_file_storage.return_value + written: list[str] = [] + removed: list[str] = [] + fh.json_dump.side_effect = lambda path, data: written.append(path) + fh.rm.side_effect = lambda path: removed.append(path) + + job_id = uuid.uuid4() + body = {"org_id": "org1", "success": True, "result": {"foo": "bar"}} + r1 = iv.FinalizeView.as_view()(_post("/x", body), job_id=job_id) # winner + r2 = iv.FinalizeView.as_view()(_post("/x", body), job_id=job_id) # loser + + assert r1.data["finalized"] is True + assert r2.data["finalized"] is False + + # Both attempts wrote a result; the two refs are DISTINCT. + assert len(written) == 2 + assert written[0] != written[1] + + # The winner's result file (written[0]) is the one mark_terminal stored as + # result_ref -- it must NOT be deleted. Only the loser's orphan is removed. + assert removed == [written[1]] + assert written[0] not in removed + + # The winner stored its OWN specific ref, not a shared deterministic path. + winner_ref = m_mark.call_args_list[0].kwargs["result_ref"] + assert winner_ref == written[0] + + +# (8) duplicate finalize: the job is already terminal (guard excludes it), +# so this is a no-op -- finalized:false and the result is NOT rewritten. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_duplicate_does_not_rewrite_result( + m_jobs, m_write, m_mark_terminal, m_release +): + job = AgentKVJob(status=JobStatus.COMPLETED, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {"foo": "bar"}}), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + assert resp.data == { + "finalized": False, + "webhook_url": "https://example.com/hook", + "status": "completed", + } + assert not m_write.called + assert not m_mark_terminal.called + + +# (8b) duplicate finalize (guard-lost) never calls delete_input either -- +# either another writer already owns cleanup, or there's nothing new to +# terminalize. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(iv, "delete_input") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_duplicate_does_not_call_delete_input( + m_jobs, m_delete_input, m_mark_terminal, m_release +): + job = AgentKVJob( + status=JobStatus.COMPLETED, + webhook_url="", + input_ref="org/o/agent_kv/j/input.pdf", + ) + m_jobs.filter.return_value.first.return_value = job + + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {}}), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + assert not m_delete_input.called + assert not m_jobs.filter.return_value.update.called + + +# (9) finalize failure records the error via mark_terminal(FAILED, ...). +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_failure_records_error(m_jobs, m_write, m_mark_terminal, m_release): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") + m_jobs.filter.return_value.first.return_value = job + m_mark_terminal.return_value = True + + job_id = uuid.uuid4() + resp = iv.FinalizeView.as_view()( + _post( + "/x", + {"org_id": "org1", "success": False, "error": "LLM provider timed out"}, + ), + job_id=job_id, + ) + + assert resp.status_code == 200 + assert resp.data == {"finalized": True, "webhook_url": "", "status": "failed"} + assert not m_write.called + m_mark_terminal.assert_called_once_with( + job_id, "org1", JobStatus.FAILED, error="LLM provider timed out" + ) + + +# (9b) finalize failure also deletes the staged input and blanks input_ref +# -- the run is over whether it completed or failed. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(iv, "delete_input") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_failure_deletes_input_and_blanks_ref( + m_jobs, m_delete_input, m_mark_terminal, m_release +): + job = AgentKVJob( + status=JobStatus.RUNNING, + webhook_url="", + input_ref="org/o/agent_kv/j/input.pdf", + ) + m_jobs.filter.return_value.first.return_value = job + + job_id = uuid.uuid4() + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": False, "error": "boom"}), + job_id=job_id, + ) + + assert resp.status_code == 200 + m_delete_input.assert_called_once_with(job) + m_jobs.filter.return_value.update.assert_called_once_with(input_ref="") + + +# (10) the concurrency slot is released on every finalize path -- including +# when the write/finalize work raises -- because release() sits in a +# finally. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_releases_slot_even_when_write_result_raises( + m_jobs, m_write, m_mark_terminal, m_release +): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") + m_jobs.filter.return_value.first.return_value = job + m_write.side_effect = RuntimeError("storage exploded") + + job_id = uuid.uuid4() + try: + iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {}}), + job_id=job_id, + ) + except RuntimeError: + pass + + m_release.assert_called_once_with("org1", str(job_id)) + assert not m_mark_terminal.called + + +# (10b) slot released on the duplicate (already-terminal, no-op) path too. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_releases_slot_on_duplicate_noop_path( + m_jobs, m_write, m_mark_terminal, m_release +): + job = AgentKVJob(status=JobStatus.CANCELLED, webhook_url="") + m_jobs.filter.return_value.first.return_value = job + + job_id = uuid.uuid4() + iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": False, "error": "late"}), + job_id=job_id, + ) + + m_release.assert_called_once_with("org1", str(job_id)) + + +# --------------------------------------------------------------------------- +# URL wiring +# --------------------------------------------------------------------------- + + +# (11) regression pin for the frozen paths (spec Interfaces block): the +# cloud executor calls these exact URLs, so a dropped/renamed include in +# internal_base_urls.py must fail loudly here rather than 404 in prod. +def test_frozen_internal_urls_resolve_to_the_right_views(): + from django.urls import resolve + + job_id = uuid.uuid4() + stage = resolve(f"/internal/v1/agent-kv/jobs/{job_id}/stage/") + assert stage.func.cls is iv.StageReportView + assert stage.kwargs == {"job_id": job_id} + + finalize = resolve(f"/internal/v1/agent-kv/jobs/{job_id}/finalize/") + assert finalize.func.cls is iv.FinalizeView + assert finalize.kwargs == {"job_id": job_id} diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py new file mode 100644 index 0000000000..a070af8d76 --- /dev/null +++ b/backend/agent_kv/tests/test_job_views.py @@ -0,0 +1,513 @@ +import os +import uuid +from datetime import timedelta +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from django.utils import timezone # noqa: E402 +from rest_framework.test import APIRequestFactory # noqa: E402 + +from agent_kv import execution_views as ev # noqa: E402 +from agent_kv import execution_views_result as evr # noqa: E402 +from agent_kv.models import AgentKVJob, AgentKVKey, JobStatus # noqa: E402 +from agent_kv.tests._factories import kv_key # noqa: E402 + + +def _authed(method="get", path="/agent-kv/x"): + req = getattr(APIRequestFactory(), method)(path) + req.META["HTTP_AUTHORIZATION"] = "Bearer 123e4567-e89b-12d3-a456-426614174001" + return req + + +# --------------------------------------------------------------------------- +# (1) status for foreign-org job -> 404 (indistinguishable from unknown). +# --------------------------------------------------------------------------- +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_foreign_org_job_is_404(m_keys, m_jobs): + m_keys.get.return_value = kv_key() + m_jobs.get.side_effect = AgentKVJob.DoesNotExist + resp = ev.JobStatusView.as_view()(_authed(), job_id=uuid.uuid4()) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# (2) status running -> stages list ordered per STAGE_NAMES, lowercased +# status, only the stages actually present are included. +# --------------------------------------------------------------------------- +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_status_running_builds_ordered_stages_and_lowercases_status(m_keys, m_jobs): + m_keys.get.return_value = kv_key() + job = AgentKVJob( + status=JobStatus.RUNNING, + stage="extraction", + stages={ + "extraction": {"status": "running"}, + "document_processing": {"status": "done", "seconds": 0.5}, + }, + pages_total=3, + ) + job.created_at = timezone.now() + m_jobs.get.return_value = job + + resp = ev.JobStatusView.as_view()(_authed(), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + # `status` is the JOB's, so it stays top level; stage reporting is + # extractor-scoped (spec §7.2). + assert resp.data["status"] == "running" + kv = resp.data["extractors"]["kv"] + assert kv["stage"] == "extraction" + # STAGE_NAMES order is document_processing, extraction, ... -- "qa" and + # every other configured stage name is absent from job.stages, so only + # these two appear, in that order. + assert [s["name"] for s in kv["stages"]] == [ + "document_processing", + "extraction", + ] + assert resp.data["pages_total"] == 3 + assert "error" not in resp.data + + +# --------------------------------------------------------------------------- +# (3) result before completion (non-terminal, e.g. RUNNING) -> 409 with the +# current status, lowercased (spec §7.3 controller ruling). +# --------------------------------------------------------------------------- +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_result_before_completion_is_409_with_current_status(m_keys, m_jobs): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING) + m_jobs.get.return_value = job + + resp = ev.JobResultView.as_view()(_authed(), job_id=uuid.uuid4()) + + assert resp.status_code == 409 + assert resp.data == {"status": "running"} + + +# --------------------------------------------------------------------------- +# (4) result after expires_at -> 404. +# --------------------------------------------------------------------------- +@mock.patch.object(evr, "read_result") +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_result_after_expiry_is_404(m_keys, m_jobs, m_read): + m_keys.get.return_value = kv_key() + job = AgentKVJob( + status=JobStatus.COMPLETED, + result_ref="org/o/agent_kv/j/result.json", + expires_at=timezone.now() - timedelta(days=1), + ) + m_jobs.get.return_value = job + + resp = ev.JobResultView.as_view()(_authed(), job_id=uuid.uuid4()) + + assert resp.status_code == 404 + assert not m_read.called + + +# --------------------------------------------------------------------------- +# (5) result happy path -> returns read_result payload unchanged. +# --------------------------------------------------------------------------- +@mock.patch.object(evr, "read_result", return_value={"success": True, "fields": {}}) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_result_happy_path_returns_read_result_payload(m_keys, m_jobs, m_read): + m_keys.get.return_value = kv_key() + job = AgentKVJob( + status=JobStatus.COMPLETED, + result_ref="org/o/agent_kv/j/result.json", + expires_at=timezone.now() + timedelta(days=1), + ) + m_jobs.get.return_value = job + + resp = ev.JobResultView.as_view()(_authed(), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + # The stored blob is the engine's own result; the response namespaces it + # per extractor and adds per-extractor usage attribution (spec §7.3). + # `success`/`status` at top level on every terminal payload, so a client + # branches the same way for completed, failed and cancelled. + assert resp.data["success"] is True + assert resp.data["status"] == "completed" + assert resp.data["extractors"] == {"kv": {"success": True, "fields": {}}} + assert set(resp.data["usage_summary"]) == {"total", "by_extractor"} + assert list(resp.data["usage_summary"]["by_extractor"]) == ["kv"] + m_read.assert_called_once_with(job.result_ref) + + +# --------------------------------------------------------------------------- +# (5b) result for a FAILED job -> 200 with a success:false body carrying the +# job's own (user-safe) error -- spec §7.3: "Failed jobs: {success: false, +# error, timing} with a user-safe error". This also covers the SubmitView +# sync-wait fix: that branch reuses this exact function unconditionally for +# any terminal job, so it now gets a correct 200 body instead of a 404. +# --------------------------------------------------------------------------- +@mock.patch.object(evr, "read_result") +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_result_for_failed_job_is_200_with_success_false_and_error( + m_keys, m_jobs, m_read +): + m_keys.get.return_value = kv_key() + job = AgentKVJob( + status=JobStatus.FAILED, + error="LLM provider timed out", + expires_at=timezone.now() + timedelta(days=1), + ) + m_jobs.get.return_value = job + + resp = ev.JobResultView.as_view()(_authed(), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + assert resp.data == { + "success": False, + "status": "failed", + "error": "LLM provider timed out", + } + assert not m_read.called + + +# --------------------------------------------------------------------------- +# (5c) result for a CANCELLED job -> 200 with a fixed success:false/cancelled +# body (spec §7.3 controller ruling). +# --------------------------------------------------------------------------- +@mock.patch.object(evr, "read_result") +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_result_for_cancelled_job_is_200_with_cancelled_body(m_keys, m_jobs, m_read): + m_keys.get.return_value = kv_key() + job = AgentKVJob( + status=JobStatus.CANCELLED, + expires_at=timezone.now() + timedelta(days=1), + ) + m_jobs.get.return_value = job + + resp = ev.JobResultView.as_view()(_authed(), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + assert resp.data == {"success": False, "status": "cancelled"} + assert not m_read.called + + +# --------------------------------------------------------------------------- +# (5d) result for a COMPLETED job with a blank result_ref (files already +# swept by TTL cleanup, row not yet expired) -> 404, same as the expired +# case -- exercises the new blank-ref branch distinctly from expiry. +# --------------------------------------------------------------------------- +@mock.patch.object(evr, "read_result") +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_result_completed_with_blank_ref_is_404(m_keys, m_jobs, m_read): + m_keys.get.return_value = kv_key() + job = AgentKVJob( + status=JobStatus.COMPLETED, + result_ref="", + expires_at=timezone.now() + timedelta(days=1), + ) + m_jobs.get.return_value = job + + resp = ev.JobResultView.as_view()(_authed(), job_id=uuid.uuid4()) + + assert resp.status_code == 404 + assert not m_read.called + + +# --------------------------------------------------------------------------- +# (6) cancel on RUNNING -> mark_terminal called with CANCELLED, 200. +# --------------------------------------------------------------------------- +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_cancel_on_running_marks_terminal_and_200s( + m_keys, m_jobs, m_mark_terminal, m_release +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING) + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + assert resp.data == {"status": "cancelled"} + m_mark_terminal.assert_called_once_with( + job.id, job.organization_id, JobStatus.CANCELLED + ) + + +# --------------------------------------------------------------------------- +# (6b) cancel that WINS the terminal guard releases the concurrency slot -- +# a job cancelled BEFORE dispatch gets no finalize callback and the sweep's +# phase-1 only targets PENDING (not CANCELLED), so without this its slot +# would leak until the 6h TTL (pre-Greptile important #4). release() is +# idempotent (zrem), so a later finalize-callback release is harmless. +# --------------------------------------------------------------------------- +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_cancel_win_releases_concurrency_slot(m_keys, m_jobs, m_mark_terminal, m_release): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.PENDING) + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + m_release.assert_called_once_with("org1", str(job.id)) + + +# --------------------------------------------------------------------------- +# (6c) cancel that LOSES the guard (job already terminal) must NOT release -- +# whoever terminalized it (finalize callback / a prior cancel) owns the slot. +# --------------------------------------------------------------------------- +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_cancel_loss_does_not_release_slot(m_keys, m_jobs, m_mark_terminal, m_release): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.COMPLETED) + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) + + assert resp.status_code == 409 + assert not m_release.called + + +# --------------------------------------------------------------------------- +# (7) cancel on COMPLETED -> 409 with current status; result untouched +# (the guard lost, so nothing about the stored result is read or written). +# --------------------------------------------------------------------------- +@mock.patch.object(evr, "read_result") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_cancel_on_completed_is_409_and_result_untouched( + m_keys, m_jobs, m_mark_terminal, m_read +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.COMPLETED) + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) + + assert resp.status_code == 409 + # Status is lowercased consistently across every endpoint (spec §7.2) -- + # the 409 body used to leak the raw uppercase value (pre-Greptile #5). + assert resp.data == {"status": "completed"} + assert m_mark_terminal.called + assert not m_read.called + + +# --------------------------------------------------------------------------- +# (8) delete calls delete_job_files and blanks both refs. +# --------------------------------------------------------------------------- +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_calls_delete_job_files_and_blanks_refs( + m_keys, m_jobs, m_delete_files, m_save +): + m_keys.get.return_value = kv_key() + job = AgentKVJob( + status=JobStatus.COMPLETED, + input_ref="org/o/agent_kv/j/input.pdf", + result_ref="org/o/agent_kv/j/result.json", + ) + m_jobs.get.return_value = job + m_delete_files.return_value = ["input_ref", "result_ref"] + + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + m_delete_files.assert_called_once_with(job) + assert job.input_ref == "" + assert job.result_ref == "" + # update_fields carries exactly the blanked refs, so a ref left set is not + # written back as "" by a wider save(). + assert m_save.call_args.kwargs["update_fields"] == ["input_ref", "result_ref"] + + +# --------------------------------------------------------------------------- +# (8b) delete on a non-terminal (RUNNING) job cancels it FIRST, before the +# files are deleted -- a still-running job that finalizes late would +# otherwise write a fresh result onto a job the caller just deleted. +# --------------------------------------------------------------------------- +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_on_running_job_cancels_before_deleting_files( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING) + job.organization_id = "org1" + m_jobs.get.return_value = job + m_delete_files.return_value = ["input_ref", "result_ref"] + + manager = mock.Mock() + manager.attach_mock(m_mark_terminal, "mark_terminal") + manager.attach_mock(m_delete_files, "delete_job_files") + + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + m_mark_terminal.assert_called_once_with(job.id, "org1", JobStatus.CANCELLED) + m_delete_files.assert_called_once_with(job) + assert [c[0] for c in manager.mock_calls] == ["mark_terminal", "delete_job_files"] + + +# --------------------------------------------------------------------------- +# (8e) DELETE on an in-flight job RELEASES the concurrency slot, exactly as +# JobCancelView does (Greptile review #2). The slot is taken at submit and +# released by _fail_job_response, the finalize callback and the sweep -- a job +# terminalized here before dispatch hits none of those, and the sweep's phase-1 +# only targets PENDING, never CANCELLED. Without the release each such delete +# leaked a slot until the 6h TTL, and enough of them exhaust the org's +# allowance and start rejecting new submits. +# --------------------------------------------------------------------------- +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_on_running_job_releases_the_concurrency_slot( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING) + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + m_release.assert_called_once_with("org1", str(job.id)) + + +# --------------------------------------------------------------------------- +# (8f) ...but only when THIS request won the terminal-state race. A concurrent +# cancel or finalize that terminalized first is the one accounting for the +# slot; releasing on a lost race would hand a slot back twice. +# --------------------------------------------------------------------------- +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_does_not_release_the_slot_when_it_loses_the_terminal_race( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING) + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + assert not m_release.called + + +# --------------------------------------------------------------------------- +# (8g) a ref whose file could NOT be deleted is left set (Greptile review #3). +# It is the only handle TTL cleanup can retry from: its candidate query matches +# rows by `input_ref > "" OR result_ref > ""`, so blanking a ref whose object is +# still in the bucket orphans that object permanently. Still 204 -- the job is +# terminal and the caller's intent is recorded. +# --------------------------------------------------------------------------- +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_keeps_the_ref_whose_file_delete_failed( + m_keys, m_jobs, m_delete_files, m_save +): + m_keys.get.return_value = kv_key() + job = AgentKVJob( + status=JobStatus.COMPLETED, + input_ref="org/o/agent_kv/j/input.pdf", + result_ref="org/o/agent_kv/j/result.json", + ) + m_jobs.get.return_value = job + # Input gone; the result delete raised inside delete_job_files. + m_delete_files.return_value = ["input_ref"] + + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + assert job.input_ref == "" + assert job.result_ref == "org/o/agent_kv/j/result.json" + assert m_save.call_args.kwargs["update_fields"] == ["input_ref"] + + +# --------------------------------------------------------------------------- +# (8c) delete on an already-terminal job never attempts to cancel it again -- +# unchanged behavior for the terminal case. +# --------------------------------------------------------------------------- +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_on_terminal_job_does_not_call_mark_terminal( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.COMPLETED) + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + assert not m_mark_terminal.called + m_delete_files.assert_called_once_with(job) + + +# --------------------------------------------------------------------------- +# (9) every job-scoped endpoint 401s (403, per Forbidden.status_code) +# without a key (spec §6.8 regression). +# +# The brief's shown snippet for this test wraps the call in +# ``pytest.raises(Forbidden)``, mirroring test_auth.py -- but that suite +# calls the ``@validate_api_key``-decorated function directly, bypassing +# DRF's dispatch(). Routed through the real ``.as_view()()`` cycle (as here, +# and as every other view in this module is exercised), DRF's dispatch() +# catches the raised ``Forbidden`` (an APIException) via +# ``drf_standardized_errors``'s exception handler and renders it as a normal +# Response -- exactly like ``test_foreign_org_job_is_404`` above asserts +# ``resp.status_code`` for ``JobNotFound`` rather than expecting a raise. +# Verified empirically (see task-9-report.md); asserting the response here +# instead keeps the same regression coverage without a spurious failure. +# --------------------------------------------------------------------------- +def test_all_job_views_401_without_key(): + for view, method in [ + (ev.JobStatusView, "get"), + (ev.JobResultView, "get"), + (ev.JobCancelView, "post"), + (ev.JobDeleteView, "delete"), + ]: + req = getattr(APIRequestFactory(), method)("/agent-kv/x") + resp = view.as_view()(req, job_id=uuid.uuid4()) + assert resp.status_code == 403 diff --git a/backend/agent_kv/tests/test_key_management.py b/backend/agent_kv/tests/test_key_management.py new file mode 100644 index 0000000000..f092f53bdd --- /dev/null +++ b/backend/agent_kv/tests/test_key_management.py @@ -0,0 +1,95 @@ +import os +import uuid +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from utils.filters.organization_filter import OrganizationFilterBackend # noqa: E402 + +from agent_kv.models import AgentKVKey # noqa: E402 +from agent_kv.serializers import AgentKVKeyWriteSerializer # noqa: E402 +from agent_kv.views import AgentKVKeyViewSet # noqa: E402 + + +def test_write_serializer_rejects_blank_name(): + s = AgentKVKeyWriteSerializer(data={"name": "", "description": "x"}) + assert not s.is_valid() + assert "name" in s.errors + + +def test_rotate_assigns_fresh_key_and_saves(): + key_obj = AgentKVKey(name="k", key=uuid.uuid4()) + old = key_obj.key + with mock.patch.object(AgentKVKey, "save") as m_save: + view = AgentKVKeyViewSet() + view.get_object = lambda: key_obj + view.format_kwarg = None + view.request = mock.Mock() + resp = view.rotate(view.request, pk=str(key_obj.id)) + assert key_obj.key != old + assert m_save.called + assert resp.status_code == 200 + + +@mock.patch.object(AgentKVKey, "objects") +def test_org_scoping_via_filter_backend_is_engaged(m_objects): + """Org-scoping is enforced by the global filter backend, not the manager. + + Two things must hold for that guard to stay real: (1) the viewset must + keep ``OrganizationFilterBackend`` in its resolved ``filter_backends`` — + so nobody can quietly swap it out later — and (2) ``get_queryset`` must + hand back the *unscoped* base queryset (``AgentKVKey.objects.all()``, + no ``organization=`` kwarg of its own) rather than pre-filtering, which + would keep working (and hide the loss of protection) even if the backend + were ever removed from ``DEFAULT_FILTER_BACKENDS``. + """ + view = AgentKVKeyViewSet() + + assert OrganizationFilterBackend in view.filter_backends + + result = view.get_queryset() + + m_objects.all.assert_called_once_with() + m_objects.filter.assert_not_called() + assert result is m_objects.all.return_value + + +def test_create_returns_id_and_key(): + """POST .../keys/ must echo the read serializer, not the write one. + + A caller who just created a key has no other way to learn its ``id`` or + the raw ``key`` value -- ``AgentKVKeyWriteSerializer`` (used to validate + the request) carries neither. ``serializer.save`` is stubbed rather than + hitting the real ORM: this file has no DB fixture, and ``ModelSerializer + .save()``'s default create path resolves ``AgentKVKey._default_manager`` + independently of any ``mock.patch.object(AgentKVKey, "objects")`` swap + (a different manager reference), so mocking ``.objects`` the way the + other tests in this file do would silently fall through to a real + database call here. + """ + key_obj = AgentKVKey( + id=uuid.uuid4(), name="k", description="", key=uuid.uuid4(), is_active=True + ) + + def _fake_save(self, **kwargs): + self.instance = key_obj + return key_obj + + view = AgentKVKeyViewSet() + view.action = "create" + view.format_kwarg = None + view.kwargs = {} + view.request = mock.Mock(data={"name": "k"}, user=mock.Mock()) + + with mock.patch.object(AgentKVKeyWriteSerializer, "save", _fake_save): + resp = view.create(view.request) + + assert resp.status_code == 201, resp.data + # DRF serializes UUID fields as strings. + assert resp.data["id"] == str(key_obj.id), resp.data + assert resp.data["key"] == str(key_obj.key), resp.data diff --git a/backend/agent_kv/tests/test_maintenance_commands.py b/backend/agent_kv/tests/test_maintenance_commands.py new file mode 100644 index 0000000000..b527b15a6c --- /dev/null +++ b/backend/agent_kv/tests/test_maintenance_commands.py @@ -0,0 +1,54 @@ +"""Management-command entrypoints for the Agent-KV maintenance jobs. + +Same no-test-DB style as the rest of ``agent_kv/tests``: real Django app +registry via ``django.setup()``, no database. ``agent_kv.maintenance``'s two +entrypoints are patched so no query ever runs -- their own behaviour +(candidate queries, guard semantics, batching) is covered where it lives now, +``agent_kv/maintenance.py``, exercised indirectly through the views in +``test_sweeps.py``. What's pinned here is only the command<->function wiring: +each command calls its maintenance function exactly once, with no arguments, +and writes the returned dict to stdout as JSON -- the shape a Kubernetes +CronJob log line needs. +""" + +import json +import os +from io import StringIO +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from django.core.management import call_command # noqa: E402 + +from agent_kv import maintenance # noqa: E402 + + +@mock.patch.object(maintenance, "run_sweep") +def test_agent_kv_sweep_command_calls_run_sweep_once_and_prints_its_result( + m_run_sweep, +): + m_run_sweep.return_value = {"swept": 2, "timed_out": 1} + out = StringIO() + + call_command("agent_kv_sweep", stdout=out) + + m_run_sweep.assert_called_once_with() + assert json.loads(out.getvalue()) == {"swept": 2, "timed_out": 1} + + +@mock.patch.object(maintenance, "run_ttl_cleanup") +def test_agent_kv_ttl_cleanup_command_calls_run_ttl_cleanup_once_and_prints_its_result( + m_run_ttl_cleanup, +): + m_run_ttl_cleanup.return_value = {"cleaned": 5} + out = StringIO() + + call_command("agent_kv_ttl_cleanup", stdout=out) + + m_run_ttl_cleanup.assert_called_once_with() + assert json.loads(out.getvalue()) == {"cleaned": 5} diff --git a/backend/agent_kv/tests/test_models.py b/backend/agent_kv/tests/test_models.py new file mode 100644 index 0000000000..6966fac515 --- /dev/null +++ b/backend/agent_kv/tests/test_models.py @@ -0,0 +1,54 @@ +"""Terminal-state write guard: the invariant everything else leans on (spec §5.4).""" + +import os +import uuid +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from agent_kv.models import AgentKVJob, JobStatus # noqa: E402 + + +def test_terminal_set_is_exactly_the_three_states(): + assert AgentKVJob.TERMINAL == frozenset( + {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED} + ) + + +@mock.patch.object(AgentKVJob, "objects") +def test_mark_terminal_excludes_terminal_rows_and_reports_success(m_objects): + m_qs = m_objects.filter.return_value.exclude.return_value + m_qs.update.return_value = 1 + job_id = uuid.uuid4() + ok = AgentKVJob.mark_terminal( + job_id=job_id, + organization_id="org1", + new_status=JobStatus.FAILED, + error="boom", + ) + assert ok is True + filter_kwargs = m_objects.filter.call_args.kwargs + assert filter_kwargs["id"] == job_id + assert filter_kwargs["organization_id"] == "org1" + _, exclude_kwargs = m_objects.filter.return_value.exclude.call_args + assert set(exclude_kwargs["status__in"]) == set(AgentKVJob.TERMINAL) + update_kwargs = m_qs.update.call_args.kwargs + assert update_kwargs["status"] == JobStatus.FAILED + assert update_kwargs["error"] == "boom" + assert "completed_at" in update_kwargs + + +@mock.patch.object(AgentKVJob, "objects") +def test_mark_terminal_on_already_terminal_row_is_noop_false(m_objects): + m_objects.filter.return_value.exclude.return_value.update.return_value = 0 + ok = AgentKVJob.mark_terminal( + job_id=uuid.uuid4(), + organization_id="org1", + new_status=JobStatus.COMPLETED, + ) + assert ok is False diff --git a/backend/agent_kv/tests/test_rate_limiter.py b/backend/agent_kv/tests/test_rate_limiter.py new file mode 100644 index 0000000000..58fa295fd0 --- /dev/null +++ b/backend/agent_kv/tests/test_rate_limiter.py @@ -0,0 +1,155 @@ +import os +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from django.conf import settings # noqa: E402 + +from agent_kv import rate_limiter as rl # noqa: E402 + + +@mock.patch.object(rl, "_redis") +@mock.patch.object(rl.time, "time", return_value=1_700_000_000.0) +def test_acquire_under_limit(m_time, m_redis): + """Acquire is ONE atomic server-side script call (trim, count, add, + expire) -- never a client-side ``ZCARD`` followed by ``ZADD``, which is a + check-then-act race that let 6 concurrent submits through a limit of 5 + in the 13b integration run. The e2e concurrency scenario is the real + proof; this pins the script's inputs. + """ + mock_redis = m_redis.return_value + mock_redis.eval.return_value = 1 + + assert rl.AgentKVConcurrencyLimiter.check_and_acquire("org1", "job1") is True + + key = "agent_kv:inflight:org1" + mock_redis.eval.assert_called_once() + args = mock_redis.eval.call_args[0] + script, numkeys, called_key, member, now, ttl_cut, limit, expire = args + assert script is rl.AgentKVConcurrencyLimiter._ACQUIRE_SCRIPT + assert (numkeys, called_key, member) == (1, key, "job1") + assert now == 1_700_000_000.0 + assert ttl_cut == 1_700_000_000.0 - rl._SLOT_TTL_SECONDS + assert limit == settings.AGENT_KV_CONCURRENT_LIMIT + assert expire == rl._SLOT_TTL_SECONDS + # No client-side check-then-act calls remain. + assert not mock_redis.zcard.called + assert not mock_redis.zadd.called + # Self-heal-before-check-before-acquire ordering lives inside the script. + body = script + assert body.index("ZREMRANGEBYSCORE") < body.index("ZCARD") < body.index("ZADD") + + +@mock.patch.object(rl, "_redis") +def test_acquire_at_limit_refused(m_redis): + m_redis.return_value.eval.return_value = 0 + assert rl.AgentKVConcurrencyLimiter.check_and_acquire("org1", "job1") is False + + +@mock.patch.object(rl, "_redis") +def test_release_removes_member(m_redis): + rl.AgentKVConcurrencyLimiter.release("org1", "job1") + m_redis.return_value.zrem.assert_called_once_with("agent_kv:inflight:org1", "job1") + + +@mock.patch.object(rl, "_redis") +def test_redis_error_during_the_script_fails_closed(m_redis): + """Was `test_redis_error_fails_open`, asserting `is True`. + + It pinned the defect as the contract: an error mid-script removed the + concurrency ceiling and the API kept accepting billable work. The failure + now surfaces as a 429 at the caller (`RateLimited`), which is recoverable; + an unbounded fan-out is not. See `AGENT_KV_LIMITER_FAIL_OPEN` for the + deliberate waiver. + """ + m_redis.return_value.eval.side_effect = ConnectionError("down") + with mock.patch.object(settings, "AGENT_KV_LIMITER_FAIL_OPEN", False): + assert rl.AgentKVConcurrencyLimiter.check_and_acquire("org1", "job1") is False + + +@mock.patch.object(rl, "_redis") +@mock.patch.object(rl.time, "time", return_value=1_700_000_000.0) +def test_key_rate_over_limit(m_time, m_redis): + mock_redis = m_redis.return_value + mock_redis.incr.return_value = 61 + + assert rl.check_key_rate("key1") is False + + expected_window = int(1_700_000_000.0 // 60) + expected_key = f"agent_kv:rate:key1:{expected_window}" + mock_redis.incr.assert_called_once_with(expected_key) + mock_redis.expire.assert_called_once_with(expected_key, 120) + + +@mock.patch.object(rl, "_redis") +@mock.patch.object(rl.time, "time", return_value=1_700_000_000.0) +def test_key_rate_under_limit(m_time, m_redis): + mock_redis = m_redis.return_value + mock_redis.incr.return_value = 3 + + assert rl.check_key_rate("key1") is True + + expected_window = int(1_700_000_000.0 // 60) + expected_key = f"agent_kv:rate:key1:{expected_window}" + mock_redis.incr.assert_called_once_with(expected_key) + mock_redis.expire.assert_called_once_with(expected_key, 120) + + +# --------------------------------------------------------------------------- +# Backend-unavailable behaviour. Both limiters used to `return True` on ANY +# Redis exception, which is the one failure mode invisible from outside: a +# Sentinel failover or pool exhaustion removed the concurrency ceiling AND the +# per-key rate ceiling simultaneously, while the API went on returning 202s for +# billable LLM work with only a per-request `logger.warning` to show for it. +# --------------------------------------------------------------------------- + + +@mock.patch.object(rl, "_redis", side_effect=OSError("redis down")) +def test_concurrency_limiter_fails_closed_by_default(m_redis): + with mock.patch.object(settings, "AGENT_KV_LIMITER_FAIL_OPEN", False): + assert rl.AgentKVConcurrencyLimiter.check_and_acquire("org1", "job1") is False + + +@mock.patch.object(rl, "_redis", side_effect=OSError("redis down")) +def test_key_rate_limiter_fails_closed_by_default(m_redis): + with mock.patch.object(settings, "AGENT_KV_LIMITER_FAIL_OPEN", False): + assert rl.check_key_rate("key1") is False + + +@mock.patch.object(rl, "_redis", side_effect=OSError("redis down")) +def test_limiters_fail_open_only_when_the_setting_says_so(m_redis): + """The waiver stays available -- availability-over-accounting is a real + operational choice -- but it has to be made deliberately, in config, rather + than being the implicit behaviour of an `except` block. + """ + with mock.patch.object(settings, "AGENT_KV_LIMITER_FAIL_OPEN", True): + assert rl.AgentKVConcurrencyLimiter.check_and_acquire("org1", "job1") is True + assert rl.check_key_rate("key1") is True + + +@mock.patch.object(rl, "_redis", side_effect=OSError("redis down")) +def test_limiter_unavailability_is_logged_at_error_level(m_redis, caplog): + """`logger.exception`, not `logger.warning`. The old level is part of why + this sat unnoticed -- a limiter being gone is an error, not a warning. + """ + with mock.patch.object(settings, "AGENT_KV_LIMITER_FAIL_OPEN", False): + with caplog.at_level("ERROR", logger=rl.logger.name): + rl.AgentKVConcurrencyLimiter.check_and_acquire("org1", "job1") + rl.check_key_rate("key1") + assert [r.levelname for r in caplog.records] == ["ERROR", "ERROR"] + # The traceback is attached, so the actual Redis failure is diagnosable. + assert all(r.exc_info for r in caplog.records) + + +@mock.patch.object(rl, "_redis", side_effect=OSError("redis down")) +def test_release_still_tolerates_an_unreachable_backend(m_redis): + """Release must stay best-effort regardless: it is called on terminal + paths (finalize, cancel, sweep) where raising would abort the caller's own + work, and a slot it cannot free expires on its own TTL. + """ + rl.AgentKVConcurrencyLimiter.release("org1", "job1") diff --git a/backend/agent_kv/tests/test_storage.py b/backend/agent_kv/tests/test_storage.py new file mode 100644 index 0000000000..257a179dea --- /dev/null +++ b/backend/agent_kv/tests/test_storage.py @@ -0,0 +1,304 @@ +import os +import re +from unittest import mock + +import django +import pytest +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from django.conf import settings # noqa: E402 +from django.test import override_settings # noqa: E402 + +from agent_kv import storage # noqa: E402 +from agent_kv.models import AgentKVJob # noqa: E402 + + +@mock.patch.object(storage, "FileSystem") +def test_stage_input_path_and_write(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + up = mock.Mock() + up.name = "invoice.PDF" + up.chunks.return_value = [b"a", b"b"] + ref = storage.stage_input("org1", "job1", up) + assert ref == "unstract/agent_kv/org1/job1/input.pdf" + assert fh.write.called + kwargs = fh.write.call_args.kwargs + assert kwargs["path"] == "unstract/agent_kv/org1/job1/input.pdf" + assert kwargs["data"] == b"ab" + + +@mock.patch.object(storage, "FileSystem") +def test_stage_input_defaults_extension_when_missing(m_fs): + up = mock.Mock() + up.name = "noext" + up.chunks.return_value = [b"x"] + ref = storage.stage_input("org1", "job1", up) + assert ref == "unstract/agent_kv/org1/job1/input.bin" + + +@mock.patch.object(storage, "FileSystem") +def test_write_and_read_result_roundtrip_path(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + ref = storage.write_result("org1", "job1", {"success": True}) + # The result path is UNIQUE per finalize attempt (a nonce suffix) so a + # guard-losing concurrent finalize deletes only its own orphan, never the + # winning row's result_ref target (pre-Greptile critical #3). It still + # lives under the deterministic job dir. + assert re.fullmatch( + r"unstract/agent_kv/org1/job1/result-[0-9a-f]{32}\.json", ref + ), ref + fh.json_dump.assert_called_once() + assert fh.json_dump.call_args.kwargs["path"] == ref + assert fh.json_dump.call_args.kwargs["data"] == {"success": True} + + +@mock.patch.object(storage, "FileSystem") +def test_write_result_produces_unique_ref_per_call(m_fs): + # Two writes for the SAME job must yield two DISTINCT refs -- this is what + # makes a concurrent duplicate-success finalize safe: the guard loser's + # delete targets its own ref, not the winner's. + a = storage.write_result("org1", "job1", {"n": 1}) + b = storage.write_result("org1", "job1", {"n": 2}) + assert a != b + assert a.startswith("unstract/agent_kv/org1/job1/result-") + assert b.startswith("unstract/agent_kv/org1/job1/result-") + + +@mock.patch.object(storage, "FileSystem") +def test_write_result_accepts_explicit_nonce(m_fs): + # A caller may pin the nonce (deterministic ref for a given attempt). + ref = storage.write_result("org1", "job1", {"n": 1}, nonce="abc123") + assert ref == "unstract/agent_kv/org1/job1/result-abc123.json" + + +@mock.patch.object(storage, "FileSystem") +def test_read_result_returns_parsed_json(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + fh.json_load.return_value = {"success": True} + out = storage.read_result("unstract/agent_kv/org1/job1/result.json") + assert out == {"success": True} + fh.json_load.assert_called_once_with(path="unstract/agent_kv/org1/job1/result.json") + + +@mock.patch.object(storage, "FileSystem") +def test_delete_job_files_removes_both_refs(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + job = AgentKVJob( + input_ref="org/o/agent_kv/j/input.pdf", + result_ref="org/o/agent_kv/j/result.json", + ) + cleared = storage.delete_job_files(job) + removed = {c.kwargs["path"] for c in fh.rm.call_args_list} + assert removed == {job.input_ref, job.result_ref} + assert cleared == ["input_ref", "result_ref"] + + +@mock.patch.object(storage, "FileSystem") +def test_delete_job_files_tolerates_missing_files(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + fh.rm.side_effect = FileNotFoundError("gone") + job = AgentKVJob( + input_ref="org/o/agent_kv/j/input.pdf", + result_ref="org/o/agent_kv/j/result.json", + ) + # Must not raise, and both count as CLEARED: the goal is "the file is not + # there", and a ref whose object is already gone has nothing to retry. + cleared = storage.delete_job_files(job) + assert fh.rm.call_count == 2 + assert cleared == ["input_ref", "result_ref"] + + +@mock.patch.object(storage, "FileSystem") +def test_delete_job_files_skips_blank_refs(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + job = AgentKVJob(input_ref="", result_ref="") + cleared = storage.delete_job_files(job) + assert not fh.rm.called + # Reported clear: nothing was ever staged, so there is nothing to retry and + # the caller is free to blank (already-blank) refs. + assert cleared == ["input_ref", "result_ref"] + + +# The Greptile-review contract (#3): a ref is reported clear ONLY when its file +# is confirmed gone. A storage error that is not FileNotFoundError leaves the +# ref out, and the ref is what TTL cleanup retries from -- its candidate query +# matches rows by `input_ref > "" OR result_ref > ""`, so a blanked ref whose +# object is still in the bucket orphans that object permanently. +@mock.patch.object(storage, "FileSystem") +def test_delete_job_files_omits_the_ref_whose_delete_raised(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + job = AgentKVJob( + input_ref="org/o/agent_kv/j/input.pdf", + result_ref="org/o/agent_kv/j/result.json", + ) + + def rm(*, path): + if path == job.result_ref: + raise OSError("object store unreachable") + + fh.rm.side_effect = rm + + cleared = storage.delete_job_files(job) + + # Still attempted both -- one failure must not abort the other's cleanup. + assert fh.rm.call_count == 2 + assert cleared == ["input_ref"] + + +@mock.patch.object(storage, "FileSystem") +def test_delete_job_files_reports_nothing_clear_when_both_deletes_raise(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + fh.rm.side_effect = OSError("object store unreachable") + job = AgentKVJob( + input_ref="org/o/agent_kv/j/input.pdf", + result_ref="org/o/agent_kv/j/result.json", + ) + assert storage.delete_job_files(job) == [] + + +# delete_input carries the same confirmed-clear contract as delete_job_files. +# It did not when the Greptile round fixed delete_job_files and its two +# callers: this third ref-blanking site (FinalizeView) was missed, because the +# fix followed one function's callers instead of grepping for every site that +# blanks a ref. A failed delete here orphaned the customer's uploaded document +# permanently -- TTL cleanup only selects rows that still carry a non-blank +# ref, so a blanked row can never be reconsidered. +@mock.patch.object(storage, "FileSystem") +def test_delete_input_reports_clear_on_success(m_fs): + job = AgentKVJob(input_ref="org/o/agent_kv/j/input.pdf") + assert storage.delete_input(job) is True + + +@mock.patch.object(storage, "FileSystem") +def test_delete_input_reports_clear_when_the_file_is_already_gone(m_fs): + m_fs.return_value.get_file_storage.return_value.rm.side_effect = FileNotFoundError + job = AgentKVJob(input_ref="org/o/agent_kv/j/input.pdf") + # Already missing is the goal state -- nothing left to retry. + assert storage.delete_input(job) is True + + +@mock.patch.object(storage, "FileSystem") +def test_delete_input_reports_NOT_clear_when_the_delete_raised(m_fs): + m_fs.return_value.get_file_storage.return_value.rm.side_effect = OSError("down") + job = AgentKVJob(input_ref="org/o/agent_kv/j/input.pdf") + # Must not raise, and must not claim the ref is clear. + assert storage.delete_input(job) is False + + +@mock.patch.object(storage, "FileSystem") +def test_delete_input_reports_clear_for_a_blank_ref(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + assert storage.delete_input(AgentKVJob(input_ref="")) is True + assert not fh.rm.called + + +@mock.patch.object(storage, "FileSystem") +def test_delete_input_removes_only_input_ref(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + job = AgentKVJob( + input_ref="org/o/agent_kv/j/input.pdf", + result_ref="org/o/agent_kv/j/result.json", + ) + storage.delete_input(job) + fh.rm.assert_called_once_with(path=job.input_ref) + + +@mock.patch.object(storage, "FileSystem") +def test_delete_input_tolerates_missing_file(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + fh.rm.side_effect = FileNotFoundError("gone") + job = AgentKVJob(input_ref="org/o/agent_kv/j/input.pdf") + # Must not raise. + storage.delete_input(job) + assert fh.rm.call_count == 1 + + +@mock.patch.object(storage, "FileSystem") +def test_delete_input_skips_blank_ref(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + job = AgentKVJob(input_ref="") + storage.delete_input(job) + assert not fh.rm.called + + +@mock.patch.object(storage, "FileSystem") +def test_delete_result_file_removes_the_given_ref(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + storage.delete_result_file("org/o/agent_kv/j/result.json") + fh.rm.assert_called_once_with(path="org/o/agent_kv/j/result.json") + + +@mock.patch.object(storage, "FileSystem") +def test_delete_result_file_tolerates_missing_file(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + fh.rm.side_effect = FileNotFoundError("gone") + # Must not raise. + storage.delete_result_file("org/o/agent_kv/j/result.json") + assert fh.rm.call_count == 1 + + +@mock.patch.object(storage, "FileSystem") +def test_delete_result_file_skips_blank_ref(m_fs): + fh = m_fs.return_value.get_file_storage.return_value + storage.delete_result_file("") + assert not fh.rm.called + + +def test_default_storage_prefix_is_bucket_rooted(): + """The shipped default roots every path in a real bucket (13b F1). + + s3fs/gcsfs read the first path segment as the bucket, so a prefix without + one (the old ``org/{org_id}/...``) makes every write fail ``NoSuchBucket``. + The default must therefore stay ``unstract/agent_kv`` -- bucket ``unstract`` + (created by the MinIO dev bootstrap), directory ``agent_kv`` -- and must + match the cloud executor's ``AGENT_KV_STORAGE_DIR_PREFIX``. + """ + if "AGENT_KV_STORAGE_DIR_PREFIX" in os.environ: + pytest.skip("AGENT_KV_STORAGE_DIR_PREFIX is overridden in this environment") + assert settings.AGENT_KV_STORAGE_DIR_PREFIX == "unstract/agent_kv" + bucket, _, rest = settings.AGENT_KV_STORAGE_DIR_PREFIX.partition("/") + # Split: a composite assert cannot say WHICH half failed, and these two + # mean different things -- no bucket segment vs. a bucket with no path. + assert bucket + assert rest + + +@mock.patch.object(storage, "FileSystem") +def test_paths_are_rooted_at_the_configured_prefix(m_fs): + up = mock.Mock() + up.name = "invoice.pdf" + up.chunks.return_value = [b"x"] + with override_settings(AGENT_KV_STORAGE_DIR_PREFIX="mybucket/kv-root"): + input_ref = storage.stage_input("org1", "job1", up) + result_ref = storage.write_result("org1", "job1", {"success": True}) + assert input_ref.startswith("mybucket/kv-root/") + assert result_ref.startswith("mybucket/kv-root/") + assert input_ref == "mybucket/kv-root/org1/job1/input.pdf" + assert re.fullmatch( + r"mybucket/kv-root/org1/job1/result-[0-9a-f]{32}\.json", result_ref + ), result_ref + + +def test_storage_prefix_normalisation_matches_executor(monkeypatch): + """A sloppy operator value must resolve to the same root on both sides: + the cloud executor strips whitespace and edge slashes before keying its + OCR cache, so the backend must too, else inputs and cache split roots. + """ + import importlib + + monkeypatch.setenv("AGENT_KV_STORAGE_DIR_PREFIX", " /unstract/agent_kv/ ") + from backend.settings import base + + importlib.reload(base) + try: + assert base.AGENT_KV_STORAGE_DIR_PREFIX == "unstract/agent_kv" + monkeypatch.setenv("AGENT_KV_STORAGE_DIR_PREFIX", " ") + importlib.reload(base) + assert base.AGENT_KV_STORAGE_DIR_PREFIX == "unstract/agent_kv" + finally: + monkeypatch.delenv("AGENT_KV_STORAGE_DIR_PREFIX", raising=False) + importlib.reload(base) diff --git a/backend/agent_kv/tests/test_submit_serializer.py b/backend/agent_kv/tests/test_submit_serializer.py new file mode 100644 index 0000000000..179bd00916 --- /dev/null +++ b/backend/agent_kv/tests/test_submit_serializer.py @@ -0,0 +1,365 @@ +"""Submit-serializer rules for the table extractor. + +This deployment routes `table` and nothing else (`EXTRACTOR_ROUTES`), so every +submit built here names `table`. The request-level rules -- file type, size, +page cap, page range, timeout, the extractor-scoped wire format -- are the same +for any extractor and are exercised through that one. + +`KVOptionsSerializer` is still in the tree and still tested, but it is tested +*directly* rather than through a submit: with `kv` out of `EXTRACTOR_ROUTES` a +`kv` entry is refused at `validate_name` before any options validator runs, so +driving those rules through `SubmitSerializer` would assert nothing about them. +""" + +import json +import os +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from django.core.files.uploadedfile import SimpleUploadedFile # noqa: E402 + +from agent_kv.execution_serializers import ( # noqa: E402 + KVOptionsSerializer, + SubmitSerializer, +) + +FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") + +#: The table extractor's `keys`: the thing being asked for is a table, named. +TABLE_KEYS = {"target_table": "Rent roll"} + + +def _pdf_upload(name="doc.pdf"): + with open(os.path.join(FIXTURES, "two_page.pdf"), "rb") as f: + return SimpleUploadedFile(name, f.read(), content_type="application/pdf") + + +# Fields that describe the REQUEST rather than an extractor (spec §7.1). +# Anything else passed to _data() is routed into the table extractor's options, +# so each test still reads as "submit with this one thing changed". +_JOB_LEVEL = { + "file", + "extractors", + "page_start", + "page_end", + "timeout", + "tags", + "custom_data", + "webhook_url", +} + + +def _data(**over): + """Build a submit payload in the extractor-scoped wire format (§7.0).""" + keys = over.pop("keys", TABLE_KEYS) + options = {k: over.pop(k) for k in list(over) if k not in _JOB_LEVEL} + d = { + "file": _pdf_upload(), + "extractors": json.dumps([{"name": "table", "keys": keys, "options": options}]), + } + d.update(over) + return d + + +def _errs(s): + """All validation errors as one string. + + Per-extractor failures surface nested under `extractors`, so asserting on a + specific top-level key would just be asserting on DRF's nesting shape rather + than on the rejection actually happening. + """ + return str(s.errors) + + +def _defaults(m): + """Set every AGENT_KV_* attribute the serializer reads to its production default.""" + m.AGENT_KV_MAX_FILE_SIZE_MB = 50 + m.AGENT_KV_MAX_PAGES = 100 + m.AGENT_KV_MAX_SCHEMA_BYTES = 262_144 + m.AGENT_KV_MAX_CALCULATIONS_BYTES = 20_000 + m.AGENT_KV_MAX_TIMEOUT_SECONDS = 300 + m.AGENT_KV_CALCULATIONS_ENABLED = False + m.AGENT_KV_STRUCTURED_OUTPUT_ENABLED = False + + +# --------------------------------------------------------------------------- +# The routing this deployment actually ships +# --------------------------------------------------------------------------- +def test_a_kv_submit_is_refused_at_the_serializer(): + """The guard on the single most important line in this carve-out. + + `agentic_kv` is not deployed here, so nothing consumes + `celery_executor_agentic_kv`. If `kv` were routable, this submit would + return 202, dispatch into a queue with no consumer, and sit in DISPATCHED + forever -- no error at the producer, nothing in any log to find. A 400 at + the serializer is the whole difference between a clear refusal and a job + that silently never runs. + """ + s = SubmitSerializer( + data=_data( + extractors=json.dumps( + [{"name": "kv", "keys": {"total": {"description": "Grand total"}}}] + ) + ) + ) + assert not s.is_valid() + assert "unknown extractor" in _errs(s) + assert "'kv'" in _errs(s) + + +def test_table_is_the_only_supported_extractor(): + from agent_kv.execution_serializers import SUPPORTED_EXTRACTORS + + assert SUPPORTED_EXTRACTORS == ("table",) + + +# --------------------------------------------------------------------------- +# Request-level rules, exercised through the extractor this deployment serves +# --------------------------------------------------------------------------- +def test_valid_submit_counts_pages_and_defaults_its_options(): + s = SubmitSerializer(data=_data()) + assert s.is_valid(), s.errors + assert s.pages_total == 2 + entry = s.validated_data["extractors"][0] + assert entry["name"] == "table" + assert entry["keys"]["target_table"] == "Rent roll" + assert entry["options"]["number_format"] == "US" + assert entry["options"]["enable_header_mapping"] is False + + +def test_a_table_entry_without_a_target_table_is_rejected(): + """`target_table` is the engine's one required extraction parameter; the + caller's actual mistake is not naming the table. + """ + s = SubmitSerializer(data=_data(keys={})) + assert not s.is_valid() + assert "target_table" in _errs(s) + + +def test_disallowed_extension_rejected(): + bad = SimpleUploadedFile("doc.exe", b"MZ", content_type="application/x-dos") + s = SubmitSerializer(data=_data(file=bad)) + assert not s.is_valid() + assert "file" in s.errors + + +def test_oversize_file_rejected(): + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + m.AGENT_KV_MAX_FILE_SIZE_MB = 0 + s = SubmitSerializer(data=_data()) + assert not s.is_valid() + assert "file" in s.errors + + +def test_page_cap_rejected(): + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + m.AGENT_KV_MAX_PAGES = 1 + s = SubmitSerializer(data=_data()) + assert not s.is_valid() + assert "pages" in str(s.errors).lower() + + +def test_extractors_not_json_rejected(): + s = SubmitSerializer(data=_data(extractors="{not json")) + assert not s.is_valid() + assert "extractors" in s.errors + + +def test_timeout_bounds(): + s = SubmitSerializer(data=_data(timeout=301)) + assert not s.is_valid() + s2 = SubmitSerializer(data=_data(timeout=0)) + assert s2.is_valid(), s2.errors + + +def test_page_range_validation(): + s = SubmitSerializer(data=_data(page_start=5, page_end=2)) + assert not s.is_valid() + + +def test_unreadable_pdf_is_field_error_and_stream_is_rewound(): + bad = SimpleUploadedFile("doc.pdf", b"not a pdf", content_type="application/pdf") + s = SubmitSerializer(data=_data(file=bad)) + assert not s.is_valid() + assert "file" in s.errors + # The `finally: f.seek(0)` in validate() must run on the error path too, + # so a downstream reader (e.g. the view persisting the upload) still sees + # the whole stream from the start. + assert bad.tell() == 0 + assert bad.read() == b"not a pdf" + + +# --------------------------------------------------------------------------- +# Extractor-scoped wire format (spec §7.0/§7.1). These rules exist so a caller +# learns immediately that something is unsupported, rather than having the +# request quietly run as something other than what they asked for. +# --------------------------------------------------------------------------- +def test_unknown_extractor_name_rejected(): + s = SubmitSerializer( + data=_data(extractors=json.dumps([{"name": "bogus", "keys": TABLE_KEYS}])) + ) + assert not s.is_valid() + assert "unknown extractor" in _errs(s) + + +def test_multiple_extractors_rejected_until_fan_out_exists(): + """The FORMAT is fixed before launch; the fan-out execution is not built. + + Accepting two entries and running only the first would be the silent kind + of wrong -- the caller is billed for a job that ignored half the request. + """ + two = [ + {"name": "table", "keys": TABLE_KEYS}, + {"name": "table", "keys": TABLE_KEYS}, + ] + s = SubmitSerializer(data=_data(extractors=json.dumps(two))) + assert not s.is_valid() + assert "multiple extractors are not supported yet" in _errs(s) + + +def test_empty_or_non_list_extractors_rejected(): + for bad in ("[]", '{"name": "table"}', '"table"'): + s = SubmitSerializer(data=_data(extractors=bad)) + assert not s.is_valid(), bad + assert "non-empty JSON array" in _errs(s) + + +def test_unknown_option_is_rejected_not_silently_dropped(): + """DRF drops unknown fields by default; for per-extractor options that would + mean a typo'd or misaddressed knob silently changing what the job runs. + """ + s = SubmitSerializer(data=_data(number_formatt="EU")) # typo for `number_format` + assert not s.is_valid() + assert "unknown options for extractor 'table'" in _errs(s) + assert "number_formatt" in _errs(s) + + +def test_a_kv_option_on_a_table_entry_is_rejected(): + """The cross-extractor case the per-extractor serializers exist for: `qa` + is a KV pipeline knob and means nothing to the table engine. Dropped + silently, the caller would believe they had turned something off. + """ + s = SubmitSerializer(data=_data(qa=False)) + assert not s.is_valid() + assert "unknown options for extractor 'table'" in _errs(s) + assert "qa" in _errs(s) + + +def test_old_flat_format_is_no_longer_accepted(): + """Hard switch (§7.1): the pre-§7.0 shape has no alias. A caller still + sending the flat form must get a clear 400, not a job that silently ran + with default options. + """ + s = SubmitSerializer( + data={ + "file": _pdf_upload(), + "keys": json.dumps(TABLE_KEYS), + "number_format": "EU", + } + ) + assert not s.is_valid() + assert "extractors" in s.errors # the now-required field is missing + + +def test_unknown_key_on_the_extractor_entry_is_rejected(): + """`options` rejects unknowns; the entry itself must too. + + DRF drops unrecognised keys, so a near-miss like `option` (singular) for + `options` would be discarded whole: `options` defaults to {}, the job runs + with every default, and the caller gets a 202 with no sign their + configuration was ignored. + """ + s = SubmitSerializer( + data=_data( + extractors=json.dumps( + [{"name": "table", "keys": TABLE_KEYS, "option": {"number_format": "EU"}}] + ) + ) + ) + assert not s.is_valid() + assert "unknown keys on extractor entry" in _errs(s) + assert "option" in _errs(s) + + +# --------------------------------------------------------------------------- +# KVOptionsSerializer, tested directly. +# +# The class is dormant on this deployment -- `kv` is not routable, so nothing +# reaches it through a submit -- but it stays in the tree so the branch that +# carries the KV engine re-enables the extractor with one line rather than a +# content merge. Dormant and untested is how a one-line re-enable turns into a +# regression, so its rules are asserted here against the serializer itself. +# --------------------------------------------------------------------------- +def _kv_options(**over): + s = KVOptionsSerializer(data=over) + return s + + +def test_kv_options_default_qa_and_challenge_on(): + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + s = _kv_options() + assert s.is_valid(), s.errors + assert s.validated_data["qa"] is True + assert s.validated_data["challenge"] is True + assert s.validated_data["extraction_mode"] == "whole-doc" + + +def test_kv_calculations_rejected_when_disabled(): + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + m.AGENT_KV_CALCULATIONS_ENABLED = False + s = _kv_options(calculations="annualize rent") + assert not s.is_valid() + assert "not available" in str(s.errors) + + +def test_kv_calculations_accepted_when_enabled(): + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + m.AGENT_KV_CALCULATIONS_ENABLED = True + assert _kv_options(calculations="annualize rent").is_valid() + + +def test_kv_calculations_cap(): + # AGENT_KV_CALCULATIONS_ENABLED must be mocked True here: real settings + # default it False, so an unmocked run hits the feature-gate branch (a + # different error) instead of the byte-size cap this test is named for. + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + m.AGENT_KV_CALCULATIONS_ENABLED = True + s = _kv_options(calculations="x" * 30_000) + assert not s.is_valid() + assert "calculations" in str(s.errors) + assert "20000 bytes" in str(s.errors) + + +def test_kv_structured_output_rejected_when_disabled(): + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + m.AGENT_KV_STRUCTURED_OUTPUT_ENABLED = False + s = _kv_options(structured_output=True) + assert not s.is_valid() + assert "structured_output" in str(s.errors) + + +def test_kv_empty_calculations_and_false_structured_output_pass_when_disabled(): + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + assert _kv_options(calculations="", structured_output=False).is_valid() + + +def test_kv_unknown_option_is_rejected(): + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + s = _kv_options(qaa=True) # typo for `qa` + assert not s.is_valid() + assert "unknown options for extractor 'kv'" in str(s.errors) diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py new file mode 100644 index 0000000000..3e5b821114 --- /dev/null +++ b/backend/agent_kv/tests/test_submit_view.py @@ -0,0 +1,733 @@ +import os +import uuid +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from django.conf import settings # noqa: E402 +from django.utils import timezone # noqa: E402 +from rest_framework.test import APIRequestFactory # noqa: E402 + +from agent_kv import execution_views as ev # noqa: E402 +from agent_kv.models import AgentKVJob, AgentKVKey, JobStatus # noqa: E402 +from agent_kv.tests._factories import kv_key # noqa: E402 + + +def _post(data=None): + return APIRequestFactory().post("/agent-kv/", data or {}, format="multipart") + + +def _authed_post(data=None): + req = _post(data) + req.META["HTTP_AUTHORIZATION"] = "Bearer 123e4567-e89b-12d3-a456-426614174001" + return req + + +# Fields describing the REQUEST rather than an extractor (spec §7.1); anything +# else an override names is routed into the kv extractor's options, so each test +# still reads as "submit with this one thing changed". +_JOB_LEVEL = { + "file", + "extractors", + "page_start", + "page_end", + "timeout", + "tags", + "custom_data", + "webhook_url", +} + + +def _valid_validated_data(**overrides): + """`SubmitSerializer.validated_data` in the extractor-scoped shape (§7.0).""" + keys = overrides.pop("keys", {"total": {"description": "Grand total"}}) + options = { + "qa": True, + "challenge": True, + "extraction_mode": "whole-doc", + "structured_output": False, + "calculations": "", + "document_class": "", + "key_notes": "", + } + options.update({k: overrides.pop(k) for k in list(overrides) if k not in _JOB_LEVEL}) + data = { + "file": mock.Mock(name="uploaded_file"), + "extractors": [{"name": "kv", "keys": keys, "options": options}], + "page_start": 1, + "page_end": None, + "timeout": 0, + "tags": [], + "custom_data": None, + "webhook_url": "", + } + data.update(overrides) + return data + + +def _mock_serializer(m_cls, **overrides): + instance = m_cls.return_value + instance.is_valid.return_value = True + instance.validated_data = _valid_validated_data(**overrides) + instance.pages_total = 3 + return instance + + +def _stamp_created_at(job, *args, **kwargs): + """save() side effect mimicking auto_now_add for a fully-mocked save(). + + A plain ``mock.patch.object(AgentKVJob, "save")`` never runs Django's real + save() machinery, so ``created_at`` (auto_now_add) is never stamped. The + view reads ``job.created_at.isoformat()`` on the 202 path, so tests that + reach it need ``autospec=True`` (to get ``self``) plus this side effect. + """ + if job.created_at is None: + job.created_at = timezone.now() + + +# --------------------------------------------------------------------------- +# 501 before anything: the agent-kv plugin probe fails first, ahead of any +# staging/dispatch/DB work. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "get_plugin", return_value=None) +@mock.patch.object(AgentKVKey, "objects") +def test_absent_plugin_501s_before_anything(m_keys, m_plugin): + m_keys.get.return_value = kv_key() + resp = ev.SubmitView.as_view()(_authed_post()) + assert resp.status_code == 501 + + +# --------------------------------------------------------------------------- +# 429: per-key rate limit refusal. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "check_key_rate", return_value=False) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_key_rate_limited_429s(m_keys, m_plugin, m_rate): + m_keys.get.return_value = kv_key() + resp = ev.SubmitView.as_view()(_authed_post()) + assert resp.status_code == 429 + assert m_rate.called + + +# --------------------------------------------------------------------------- +# 429: concurrency-slot refusal. No job row must be persisted (save() never +# called) and staging must never run. +# --------------------------------------------------------------------------- +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "stage_input") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_concurrency_limited_429s_with_no_job_row( + m_keys, m_plugin, m_rate, m_serializer_cls, m_limiter, m_stage, m_save +): + m_keys.get.return_value = kv_key() + _mock_serializer(m_serializer_cls) + m_limiter.check_and_acquire.return_value = False + + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 429 + assert not m_save.called + assert not m_stage.called + + +# --------------------------------------------------------------------------- +# Staging/save failure: the concurrency slot must be released and the +# client gets a user-safe 500 — no leaked exception text, no stuck slot. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "stage_input") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_stage_input_failure_releases_slot_and_500s_with_safe_body( + m_keys, m_plugin, m_rate, m_serializer_cls, m_stage, m_limiter +): + m_keys.get.return_value = kv_key() + _mock_serializer(m_serializer_cls) + m_limiter.check_and_acquire.return_value = True + m_stage.side_effect = OSError("object store unreachable: leaked-secret-bucket-key") + + with mock.patch.object(AgentKVJob, "mark_terminal") as m_mark_terminal: + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 500 + # The internal exception text must never reach the client. + assert "leaked-secret-bucket-key" not in str(resp.data) + assert resp.data["status"] == JobStatus.FAILED.lower() + assert resp.data["error"] == "Job could not be accepted; nothing was billed." + assert "job_id" in resp.data + + # stage_input raised before job.save() ever ran: no row exists, so + # mark_terminal must not be called against a nonexistent job. + assert not m_mark_terminal.called + assert m_limiter.release.called + released_job_id = m_limiter.release.call_args.args[1] + assert released_job_id == resp.data["job_id"] + + +# --------------------------------------------------------------------------- +# Happy path: 202 with job_id/status/status_url; staging + dispatch happen; +# job is persisted. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save", autospec=True) +@mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_happy_path_returns_202_with_job_id_status_and_status_url( + m_keys, + m_plugin, + m_rate, + m_serializer_cls, + m_limiter, + m_stage, + m_save, + m_dispatch, +): + m_keys.get.return_value = kv_key() + _mock_serializer(m_serializer_cls) + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + + def _side_effect(job, *, extractor, schema, options): + job.status = JobStatus.DISPATCHED + job.dispatched_at = timezone.now() + + m_dispatch.side_effect = _side_effect + + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 202 + assert {"job_id", "status", "status_url"} <= set(resp.data.keys()) + # job_id must be a real UUID string. + uuid.UUID(resp.data["job_id"]) + assert ( + resp.data["status_url"] + == f"/{settings.AGENT_KV_PATH_PREFIX}/{resp.data['job_id']}" + ) + assert resp.data["status"] == JobStatus.DISPATCHED.lower() + + assert m_limiter.check_and_acquire.called + assert m_stage.called + assert m_save.called + assert m_dispatch.called + + +# --------------------------------------------------------------------------- +# The 9-key options dict SubmitView.post builds and forwards to dispatch_job +# must map every field correctly and carry the compiled schema -- a +# dropped/typo'd key here would otherwise pass silently since nothing else +# asserts on dispatch_job's call args. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save", autospec=True) +@mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_dispatch_job_called_with_expected_options_and_schema( + m_keys, + m_plugin, + m_rate, + m_serializer_cls, + m_limiter, + m_stage, + m_save, + m_dispatch, +): + m_keys.get.return_value = kv_key() + keys_schema = {"total": {"description": "Grand total"}} + _mock_serializer( + m_serializer_cls, + keys=keys_schema, + qa=False, + challenge=False, + extraction_mode="per-page", + structured_output=True, + page_start=2, + page_end=5, + document_class="invoice", + key_notes="ignore footers", + calculations="annualize rent", + ) + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + + ev.SubmitView.as_view()(_authed_post()) + + assert m_dispatch.called + kwargs = m_dispatch.call_args.kwargs + assert kwargs["schema"] == keys_schema + assert kwargs["options"] == { + "qa": False, + "challenge": False, + "extraction_mode": "per-page", + "structured_output": True, + "page_start": 2, + "page_end": 5, + "document_class": "invoice", + "key_notes": "ignore footers", + "calculations": "annualize rent", + } + + +# --------------------------------------------------------------------------- +# Dispatch failure: job marked FAILED via mark_terminal, concurrency slot +# released, 500 response with a user-safe (non-leaking) error message. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_dispatch_failure_marks_job_failed_releases_slot_and_500s( + m_keys, + m_plugin, + m_rate, + m_serializer_cls, + m_stage, + m_save, + m_dispatch, + m_mark_terminal, + m_limiter, +): + m_keys.get.return_value = kv_key() + _mock_serializer(m_serializer_cls) + m_limiter.check_and_acquire.return_value = True + m_dispatch.side_effect = ev.DispatchError("broker credentials: super-secret-token") + + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 500 + # The internal exception text must never reach the client. + assert "super-secret-token" not in str(resp.data) + assert resp.data["status"] == JobStatus.FAILED.lower() + assert "job_id" in resp.data + + assert m_mark_terminal.called + call = m_mark_terminal.call_args + assert call.args[2] == JobStatus.FAILED + assert call.kwargs["error"] == "Job could not be dispatched; nothing was billed." + + assert m_limiter.release.called + released_job_id = m_limiter.release.call_args.args[1] + assert released_job_id == str(call.args[0]) + + +# --------------------------------------------------------------------------- +# Belt-and-braces: a raw (non-DispatchError) exception out of dispatch_job +# must get IDENTICAL cleanup to a DispatchError — nothing may escape +# unhandled. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_dispatch_job_raising_non_dispatch_error_is_still_caught_and_cleaned_up( + m_keys, + m_plugin, + m_rate, + m_serializer_cls, + m_stage, + m_save, + m_dispatch, + m_mark_terminal, + m_limiter, +): + m_keys.get.return_value = kv_key() + _mock_serializer(m_serializer_cls) + m_limiter.check_and_acquire.return_value = True + m_dispatch.side_effect = RuntimeError("unexpected: leaked-secret-abc") + + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 500 + assert "leaked-secret-abc" not in str(resp.data) + assert resp.data["status"] == JobStatus.FAILED.lower() + assert resp.data["error"] == "Job could not be dispatched; nothing was billed." + + assert m_mark_terminal.called + assert m_mark_terminal.call_args.args[2] == JobStatus.FAILED + assert m_limiter.release.called + + +# --------------------------------------------------------------------------- +# End-to-end regression for the widened dispatch.py try: a raw RuntimeError +# from the platform-key lookup deep inside the REAL dispatch_job must still +# result in mark_terminal + release + a safe 500 at the view layer. +# --------------------------------------------------------------------------- +@mock.patch("agent_kv.dispatch._platform_api_key") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(AgentKVJob, "save", autospec=True) +@mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_platform_key_lookup_failure_inside_real_dispatch_is_caught_end_to_end( + m_keys, + m_plugin, + m_rate, + m_serializer_cls, + m_stage, + m_save, + m_limiter, + m_mark_terminal, + m_platform_key, +): + # ev.dispatch_job is intentionally left real here — only the platform-key + # lookup deep inside it is mocked to raise, proving the widened + # dispatch.py try (Fix 2a) plus the view's cleanup (Fix 2b) work together. + m_keys.get.return_value = kv_key() + _mock_serializer(m_serializer_cls) + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + m_platform_key.side_effect = RuntimeError("platform db down: leaked-secret-xyz") + + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 500 + assert "leaked-secret-xyz" not in str(resp.data) + assert resp.data["status"] == JobStatus.FAILED.lower() + assert resp.data["error"] == "Job could not be dispatched; nothing was billed." + + assert m_mark_terminal.called + assert m_limiter.release.called + + +# --------------------------------------------------------------------------- +# timeout=0: the wait branch must never run (no sleep, no polling, no +# attempt to import the Task-9 result_payload module). +# --------------------------------------------------------------------------- +@mock.patch("agent_kv.execution_views.time.sleep") +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save", autospec=True) +@mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_timeout_zero_returns_immediately_without_polling( + m_keys, + m_plugin, + m_rate, + m_serializer_cls, + m_limiter, + m_stage, + m_save, + m_dispatch, + m_sleep, +): + m_keys.get.return_value = kv_key() + _mock_serializer(m_serializer_cls, timeout=0) + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + + with mock.patch.object(AgentKVJob, "refresh_from_db") as m_refresh: + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 202 + assert not m_sleep.called + assert not m_refresh.called + + +# --------------------------------------------------------------------------- +# Sync-wait regression (spec §7.3 controller ruling on task-9-report.md +# concern 3): if the job fails during the wait window, the inline +# synchronous response must still be a 200 carrying the failure body -- not +# a 404. Before the fix, ``result_payload`` raised ``JobNotFound`` for any +# terminal-but-not-COMPLETED job (blank ``result_ref``), which escaped this +# view as an unhandled 404. Uses the real (unmocked) ``result_payload`` so +# the fix is exercised end-to-end, not just at the unit level. +# --------------------------------------------------------------------------- +@mock.patch("agent_kv.execution_views.time.sleep") +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save", autospec=True) +@mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_sync_wait_returns_200_with_failure_body_when_job_fails_mid_wait( + m_keys, + m_plugin, + m_rate, + m_serializer_cls, + m_limiter, + m_stage, + m_save, + m_dispatch, + m_sleep, +): + m_keys.get.return_value = kv_key() + _mock_serializer(m_serializer_cls, timeout=5) + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + + def _refresh_side_effect(job, *args, **kwargs): + job.status = JobStatus.FAILED + job.error = "LLM provider timed out" + + with mock.patch.object( + AgentKVJob, "refresh_from_db", autospec=True, side_effect=_refresh_side_effect + ): + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 200 + assert resp.data == { + "success": False, + "status": "failed", + "error": "LLM provider timed out", + } + assert not m_sleep.called + + +# --------------------------------------------------------------------------- +# Subscription admission (§6.6, following the API deployment path). +# +# Deployments are billed-gated by cloud's SubscriptionMiddleware, which resolves +# the org from the URL (/deployment/api/{org_name}/...). Agent-KV's URL carries +# no org segment -- the org lives in the Bearer key -- so that middleware +# resolves org_id=None for these requests and lets every one of them through. +# The gate is therefore invoked here, after key validation. +# --------------------------------------------------------------------------- +def _key_with_org(slug="acme-slug", pk=42): + """A key whose FK pk and org slug are deliberately DIFFERENT values. + + `Subscription.organization_id` is a CharField holding the slug; the FK pk is + an int. Making them differ is what lets the tests below detect the wrong one + being passed -- with a single shared value the assertion would pass either + way and the gate could silently never match a subscription row. + """ + from account_v2.models import Organization # noqa: PLC0415 + + key = AgentKVKey(name="k", is_active=True) + key.organization = Organization(id=pk, organization_id=slug) + return key + + +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "stage_input") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(AgentKVKey, "objects") +def test_subscription_denial_is_returned_verbatim_and_starts_no_work( + m_keys, m_rate, m_serializer_cls, m_limiter, m_stage, m_save +): + """A 402 from the gate must reach the client unchanged -- same status and + body an API deployment returns for the same subscription state -- and must + stop the request before any billable work, slot or row. + """ + from django.http import HttpResponse # noqa: PLC0415 + + m_keys.get.return_value = _key_with_org() + _mock_serializer(m_serializer_cls) + denial = HttpResponse(b'{"errors": "Trial period expired."}', status=402) + gate = mock.Mock() + gate.check.return_value = denial + + with mock.patch.object( + ev, "get_plugin", return_value={"module": object(), "service_class": lambda: gate} + ): + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 402 + assert not m_save.called + assert not m_stage.called + assert not m_limiter.check_and_acquire.called + + +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(AgentKVKey, "objects") +def test_subscription_gate_is_passed_the_org_slug_not_the_fk_pk( + m_keys, m_rate, m_serializer_cls, m_limiter +): + """The gate must receive `key.organization.organization_id` (the slug that + `Subscription.organization_id` is keyed on), NOT `key.organization_id` (the + Organization FK primary key). + + Passing the pk matches no subscription row, and the shared policy reads "no + row" as "nothing to enforce" -- so the gate would admit every request while + looking fully wired. This test is the only thing standing between that + one-attribute slip and a billing gate that never fires. + """ + m_keys.get.return_value = _key_with_org(slug="acme-slug", pk=42) + _mock_serializer(m_serializer_cls) + m_limiter.check_and_acquire.return_value = False # stop early; gate already ran + gate = mock.Mock() + gate.check.return_value = None + + with mock.patch.object( + ev, "get_plugin", return_value={"module": object(), "service_class": lambda: gate} + ): + ev.SubmitView.as_view()(_authed_post()) + + assert gate.check.called + passed_org = gate.check.call_args.args[0] + assert passed_org == "acme-slug", f"gate got {passed_org!r}, expected the org slug" + assert passed_org != 42 + + +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_plugin_without_a_service_class_still_proceeds( + m_keys, m_plugin, m_rate, m_serializer_cls, m_limiter +): + """A cloud build predating the gate exposes no `service_class`. The view + must degrade to today's behaviour rather than 500 -- the capability probe + above already 501s an engine-less deployment, so this only covers the + version skew between an older plugin and a newer backend. + """ + m_keys.get.return_value = _key_with_org() + _mock_serializer(m_serializer_cls) + m_limiter.check_and_acquire.return_value = False + + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 429 # reached the concurrency limiter, not a 500 + + +# --------------------------------------------------------------------------- +# Integration: the REAL serializer through the REAL view. +# +# Every other test in this module patches SubmitSerializer and feeds the view a +# `validated_data` built by _valid_validated_data() -- so the view is asserted +# against a shape this file makes up, not the one the serializer actually +# emits. The serializer suite has the mirror-image blind spot: it validates +# payloads and never runs a view. Both pass even if the two disagree, which is +# precisely the seam an extractor-scoped wire format (§7.0) moves. +# +# This test builds a real multipart upload, runs it through the real serializer +# and the real view, and asserts what reaches dispatch_job -- the frozen +# OSS<->cloud contract on the far side. +# --------------------------------------------------------------------------- +def _real_multipart_post(extractors, **job_level): + import json as _json # noqa: PLC0415 + import os as _os # noqa: PLC0415 + + from django.core.files.uploadedfile import SimpleUploadedFile # noqa: PLC0415 + + fixture = _os.path.join(_os.path.dirname(__file__), "fixtures", "two_page.pdf") + with open(fixture, "rb") as fh: + upload = SimpleUploadedFile("doc.pdf", fh.read(), content_type="application/pdf") + payload = {"file": upload, "extractors": _json.dumps(extractors), **job_level} + req = APIRequestFactory().post("/agent-kv/", payload, format="multipart") + req.META["HTTP_AUTHORIZATION"] = "Bearer 123e4567-e89b-12d3-a456-426614174001" + return req + + +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save", autospec=True) # autospec: see _stamp_created_at +@mock.patch.object(ev, "stage_input") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_real_serializer_through_real_view_reaches_dispatch_intact_for_table( + m_keys, m_plugin, m_rate, m_limiter, m_stage, m_save, m_dispatch +): + """The table path's version of the test above -- and the exact contract + that already broke once: an earlier version had the consumer reading + `target_table` off the top level of `executor_params` while the producer + nested it under `schema`, which would have failed every table job, and it + survived two rounds of unit tests on both sides because each side was + internally consistent on its own. Only a test that runs the real + serializer AND the real view together, and inspects what lands at + `dispatch_job`, can catch that kind of drift. + """ + m_keys.get.return_value = kv_key() + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + schema = {"target_table": "Rent rolls"} + + resp = ev.SubmitView.as_view()( + _real_multipart_post( + [ + { + "name": "table", + "keys": schema, + "options": {"instructions": "skip totals"}, + } + ], + ) + ) + + assert resp.status_code == 202, resp.data + kwargs = m_dispatch.call_args.kwargs + assert kwargs["extractor"] == "table" + # Nested under `schema`, matching exactly what the cloud executor reads + # (`params["schema"]["target_table"]") -- NOT hoisted to the top level. + assert kwargs["schema"] == {"target_table": "Rent rolls"} + assert kwargs["options"]["instructions"] == "skip totals" + + # The job row records which extractor it ran (execution_views.py:142) -- + # asserted here since nothing else in this suite reads it back. + job = m_dispatch.call_args.args[0] + assert job.extractor == "table" + + +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(AgentKVKey, "objects") +def test_real_serializer_rejects_the_old_flat_shape_with_400(m_keys, m_plugin, m_rate): + """End-to-end proof of the hard switch: a caller on the pre-§7.0 format gets + a 400 from the real stack, not a job that quietly ran with defaults. + """ + import json as _json # noqa: PLC0415 + import os as _os # noqa: PLC0415 + + from django.core.files.uploadedfile import SimpleUploadedFile # noqa: PLC0415 + + m_keys.get.return_value = kv_key() + fixture = _os.path.join(_os.path.dirname(__file__), "fixtures", "two_page.pdf") + with open(fixture, "rb") as fh: + upload = SimpleUploadedFile("doc.pdf", fh.read(), content_type="application/pdf") + req = APIRequestFactory().post( + "/agent-kv/", + { + "file": upload, + "keys": _json.dumps({"total": {"description": "T"}}), + "qa": "false", + }, + format="multipart", + ) + req.META["HTTP_AUTHORIZATION"] = "Bearer 123e4567-e89b-12d3-a456-426614174001" + + resp = ev.SubmitView.as_view()(req) + + assert resp.status_code == 400 + assert "extractors" in str(resp.data) diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py new file mode 100644 index 0000000000..be1cf6b4f0 --- /dev/null +++ b/backend/agent_kv/tests/test_sweeps.py @@ -0,0 +1,646 @@ +"""Agent-KV never-dispatched sweep and TTL cleanup internal endpoints +(spec §5.4, task-14-brief.md). + +Same mock-based style as test_internal_views.py: no real DB, every +``AgentKVJob.objects`` (and, for TTLCleanupView, ``delete_job_files``) call +is mocked and its arguments/ordering are asserted directly. That is also +the mechanism for the two predicate-shaped guarantees in the brief that a +mock can't literally execute against a database: + +* "non-expired jobs untouched" -- proven by asserting the exact + ``expires_at__lt`` filter kwarg the candidate query is built with. +* "blank-ref rows excluded / a second run over the same set is a no-op" -- + proven by asserting the exact ``Q(input_ref__gt="") | Q(result_ref__gt="")`` + filter the candidate query is built with: a row TTLCleanupView just + blanked no longer satisfies that predicate, so it drops out of the next + call's candidate set. + +These tests exercise the two-phase sweep and TTL-cleanup logic through the +(now-thin) ``SweepView``/``TTLCleanupView`` -- the logic itself lives in +``agent_kv.maintenance`` (moved there so the ``agent_kv_sweep``/ +``agent_kv_ttl_cleanup`` management commands can share it), which is why +``delete_job_files`` is patched on the ``maintenance`` module below rather +than on ``internal_views``. +""" + +import os +import uuid +from datetime import timedelta +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from django.conf import settings # noqa: E402 +from django.db.models import Q # noqa: E402 +from django.utils import timezone # noqa: E402 +from rest_framework.test import APIRequestFactory # noqa: E402 + +from agent_kv import internal_views as iv # noqa: E402 +from agent_kv import maintenance # noqa: E402 +from agent_kv.models import AgentKVJob, JobStatus # noqa: E402 + + +def _post(path, body=None): + return APIRequestFactory().post(path, body or {}, format="json") + + +# --------------------------------------------------------------------------- +# SweepView +# --------------------------------------------------------------------------- +# +# SweepView runs two independent phases per call -- never-dispatched PENDING +# jobs, then stuck DISPATCHED/RUNNING jobs -- each its own +# filter().order_by()[:500] chain against the same (mocked) AgentKVJob.objects +# manager. ``_wire_sweep_phases`` gives each phase call its own Mock object +# (via `side_effect`, keyed on call ORDER: phase 1 first, then phase 2) so a +# test can assert on -- and control the candidates of -- one phase without the +# other phase's identical-shaped chain aliasing it. + + +def _wire_sweep_phases(m_objects, never_dispatched=(), stuck=()): + """Wire the sweep's two phase queries. + + Phase 2 is `filter(status__in=...).filter(Q(...) | Q(...))` -- two levels, + because the age test is an OR: `dispatched_at < cutoff` OR + `dispatched_at IS NULL AND created_at < cutoff`. A `__lt` filter alone can + never match a NULL (SQL `NULL < x` is not true), so without that second arm + a DISPATCHED/RUNNING row with a NULL `dispatched_at` hangs forever. + """ + phase1_qs = mock.MagicMock() + phase1_qs.order_by.return_value.__getitem__.return_value = list(never_dispatched) + phase2_status_qs = mock.MagicMock() + phase2_qs = phase2_status_qs.filter.return_value + phase2_qs.order_by.return_value.__getitem__.return_value = list(stuck) + m_objects.filter.side_effect = [phase1_qs, phase2_status_qs] + return phase1_qs, phase2_qs, phase2_status_qs + + +# (1) the never-dispatched-phase candidate query is exactly PENDING + older +# than the grace + dispatched_at IS NULL -- assert the filter kwargs directly +# (this IS the proof that "only PENDING+old+undispatched" are swept; nothing +# else does a real DB round trip in this suite) -- and, per the task-14-review +# ruling, oldest-created-first and capped at 500: an unbounded queryset would +# load a whole infra-incident backlog into memory and hold the request open +# through it, exactly when the sweep matters most. +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_sweep_queries_pending_older_than_grace_and_undispatched( + m_objects, m_mark_terminal +): + frozen_now = timezone.now() + phase1_qs, phase2_qs, phase2_status_qs = _wire_sweep_phases(m_objects) + + with mock.patch.object(timezone, "now", return_value=frozen_now): + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"swept": 0, "timed_out": 0} + filter_kwargs = m_objects.filter.call_args_list[0].kwargs + assert filter_kwargs["status"] == JobStatus.PENDING + assert filter_kwargs["dispatched_at__isnull"] is True + assert filter_kwargs["created_at__lt"] == frozen_now - timedelta( + seconds=settings.AGENT_KV_SWEEP_GRACE_SECONDS + ) + phase1_qs.order_by.assert_called_once_with("created_at") + phase1_qs.order_by.return_value.__getitem__.assert_called_once_with( + slice(None, 500, None) + ) + assert not m_mark_terminal.called + + +# (2) each never-dispatched candidate is terminalized via +# mark_terminal(FAILED, "Job was never dispatched") -- the guarded write, not +# a raw .update(). +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_sweep_terminalizes_each_candidate_as_failed_never_dispatched( + m_objects, m_mark_terminal, m_release +): + job = AgentKVJob(id=uuid.uuid4(), organization_id="org1") + _wire_sweep_phases(m_objects, never_dispatched=[job]) + m_mark_terminal.return_value = True + + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + m_mark_terminal.assert_called_once_with( + job.id, "org1", JobStatus.FAILED, error="Job was never dispatched" + ) + + +# (3) a job the guard actually wins gets its concurrency slot released, with +# its own org id and job id. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_sweep_releases_the_concurrency_slot_of_each_swept_job( + m_objects, m_mark_terminal, m_release +): + job = AgentKVJob(id=uuid.uuid4(), organization_id="org7") + _wire_sweep_phases(m_objects, never_dispatched=[job]) + m_mark_terminal.return_value = True + + iv.SweepView.as_view()(_post("/x")) + + m_release.assert_called_once_with("org7", str(job.id)) + + +# (4) a candidate that LOSES the mark_terminal guard (raced to terminal by +# a concurrent finalize/cancel/duplicate sweep between the candidate read +# and the guarded write) is not counted as swept and its slot is not +# released here -- whichever path won the race already released it. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_sweep_count_reflects_guard_outcomes_not_candidate_count( + m_objects, m_mark_terminal, m_release +): + won_job = AgentKVJob(id=uuid.uuid4(), organization_id="org1") + lost_job = AgentKVJob(id=uuid.uuid4(), organization_id="org1") + _wire_sweep_phases(m_objects, never_dispatched=[won_job, lost_job]) + m_mark_terminal.side_effect = [True, False] + + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"swept": 1, "timed_out": 0} + m_release.assert_called_once_with("org1", str(won_job.id)) + + +# (5) no candidates -> {"swept": 0, "timed_out": 0}, and no terminalize/ +# release side effects. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_sweep_with_no_candidates_is_a_pure_noop(m_objects, m_mark_terminal, m_release): + _wire_sweep_phases(m_objects) + + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"swept": 0, "timed_out": 0} + assert not m_mark_terminal.called + assert not m_release.called + + +# --------------------------------------------------------------------------- +# SweepView -- stuck-job (phase 2) terminalizer (Fix 8) +# --------------------------------------------------------------------------- + + +# (5a) the stuck-job-phase candidate query is exactly +# DISPATCHED/RUNNING + dispatched_at older than the stuck grace, ordered +# oldest-dispatched-first and capped at 500 -- same batch-safety rationale as +# phase 1. +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_stuck_sweep_queries_dispatched_and_running_older_than_stuck_grace( + m_objects, m_mark_terminal +): + frozen_now = timezone.now() + phase1_qs, phase2_qs, phase2_status_qs = _wire_sweep_phases(m_objects) + + with mock.patch.object(timezone, "now", return_value=frozen_now): + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"swept": 0, "timed_out": 0} + filter_kwargs = m_objects.filter.call_args_list[1].kwargs + assert set(filter_kwargs["status__in"]) == {JobStatus.DISPATCHED, JobStatus.RUNNING} + cutoff = frozen_now - timedelta(seconds=settings.AGENT_KV_STUCK_JOB_GRACE_SECONDS) + # The age test is an OR, and the second arm is load-bearing: a `__lt` + # filter alone can never match a NULL `dispatched_at`, so such a row would + # be invisible to this phase AND to phase 1 (which requires PENDING), and + # would hang non-terminal forever. A row with no dispatch time falls back + # to `created_at` -- the only timestamp it has. + (age_q,), age_kwargs = phase2_status_qs.filter.call_args + assert age_kwargs == {} + assert age_q == ( + Q(dispatched_at__lt=cutoff) | Q(dispatched_at__isnull=True, created_at__lt=cutoff) + ) + phase2_qs.order_by.assert_called_once_with("dispatched_at") + phase2_qs.order_by.return_value.__getitem__.assert_called_once_with( + slice(None, 500, None) + ) + assert not m_mark_terminal.called + + +# (5b) each stuck candidate is terminalized via mark_terminal(FAILED, +# "Job timed out") -- distinct error text from the never-dispatched phase. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_stuck_sweep_terminalizes_each_candidate_as_failed_timed_out( + m_objects, m_mark_terminal, m_release +): + job = AgentKVJob(id=uuid.uuid4(), organization_id="org1", status=JobStatus.RUNNING) + _wire_sweep_phases(m_objects, stuck=[job]) + m_mark_terminal.return_value = True + + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"swept": 0, "timed_out": 1} + m_mark_terminal.assert_called_once_with( + job.id, "org1", JobStatus.FAILED, error="Job timed out" + ) + + +# (5c) a stuck job the guard wins gets its concurrency slot released. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_stuck_sweep_releases_the_concurrency_slot_of_each_timed_out_job( + m_objects, m_mark_terminal, m_release +): + job = AgentKVJob(id=uuid.uuid4(), organization_id="org9", status=JobStatus.DISPATCHED) + _wire_sweep_phases(m_objects, stuck=[job]) + m_mark_terminal.return_value = True + + iv.SweepView.as_view()(_post("/x")) + + m_release.assert_called_once_with("org9", str(job.id)) + + +# (5d) a stuck candidate that LOSES the guard (raced to terminal by a +# concurrent finalize/cancel/duplicate sweep) is not counted as timed_out and +# its slot is not released here. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_stuck_sweep_count_reflects_guard_outcomes_not_candidate_count( + m_objects, m_mark_terminal, m_release +): + won_job = AgentKVJob(id=uuid.uuid4(), organization_id="org1") + lost_job = AgentKVJob(id=uuid.uuid4(), organization_id="org1") + _wire_sweep_phases(m_objects, stuck=[won_job, lost_job]) + m_mark_terminal.side_effect = [True, False] + + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"swept": 0, "timed_out": 1} + m_release.assert_called_once_with("org1", str(won_job.id)) + + +# (5e) the two phases' counts are independent -- a hit in one phase doesn't +# affect the other's count, and both run on the same call. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "objects") +def test_sweep_reports_both_phase_counts_independently( + m_objects, m_mark_terminal, m_release +): + never_dispatched_job = AgentKVJob(id=uuid.uuid4(), organization_id="org1") + stuck_job = AgentKVJob(id=uuid.uuid4(), organization_id="org1") + _wire_sweep_phases( + m_objects, never_dispatched=[never_dispatched_job], stuck=[stuck_job] + ) + m_mark_terminal.return_value = True + + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"swept": 1, "timed_out": 1} + + +# --------------------------------------------------------------------------- +# TTLCleanupView +# --------------------------------------------------------------------------- + + +class _Lane: + """One of run_ttl_cleanup's two candidate queries, recording how it was used. + + A real object rather than a ``Mock``: the lane is sorted and then SLICED, + and a Mock whose ``__getitem__`` is wrapped to record the slice ends up + calling itself (the wrapper re-enters the same mock), which is how the + first version of this helper recursed instead of asserting. + """ + + def __init__(self, rows): + self.rows = list(rows) + self.order_by_args = None + self.slice = None + + def order_by(self, *args): + self.order_by_args = args + return self + + def __getitem__(self, sl): + self.slice = sl + return self.rows[sl] + + +class _Lanes: + """Stand-in for the narrowed expired queryset, dispatching to either lane. + + run_ttl_cleanup narrows the expired set once and queries it TWICE -- + ``cleanup_failed_at__isnull=False`` (retries, a reserved slice) and + ``...=True`` (new expirations, the remainder). A plain ``Mock`` hands back + the same child for both calls regardless of arguments, so the two lanes + would yield identical rows and every job would be processed twice. + """ + + def __init__(self, retries, fresh): + self.retry = _Lane(retries) + self.fresh = _Lane(fresh) + self.filter_kwargs: list[dict] = [] + + def filter(self, *_args, **kwargs): + self.filter_kwargs.append(kwargs) + is_null = kwargs.get("cleanup_failed_at__isnull") + return self.retry if is_null is False else self.fresh + + +def _ttl_lanes(m_objects, *, retries=(), fresh=()): + """Wire a mocked ``AgentKVJob.objects`` to both TTL-cleanup lanes.""" + lanes = _Lanes(retries, fresh) + m_objects.filter.return_value.filter.return_value = lanes + return lanes + + +# (6) the candidate query is exactly expires_at < now AND (non-blank +# input_ref OR non-blank result_ref), capped at 500 across both lanes. +# This is what proves BOTH "non-expired untouched" (the expires_at__lt +# half) and "blank-ref rows excluded / second run is a no-op" (the Q half: +# a row TTLCleanupView just blanked no longer satisfies `__gt=""`). +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_queries_expired_jobs_with_a_nonblank_ref(m_objects, m_delete): + frozen_now = timezone.now() + lanes = _ttl_lanes(m_objects) + + with mock.patch.object(timezone, "now", return_value=frozen_now): + resp = iv.TTLCleanupView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"cleaned": 0, "retained": 0} + assert m_objects.filter.call_args_list[0].kwargs == {"expires_at__lt": frozen_now} + (q_arg,), q_kwargs = m_objects.filter.return_value.filter.call_args + assert q_kwargs == {} + assert q_arg == (Q(input_ref__gt="") | Q(result_ref__gt="")) + # Two lanes off that one narrowed set: retries first, then new expirations. + assert lanes.filter_kwargs == [ + {"cleanup_failed_at__isnull": False}, + {"cleanup_failed_at__isnull": True}, + ] + assert not m_delete.called + + +# (6b) each lane sorts by a SINGLE named column ascending -- never +# `cleanup_failed_at NULLS FIRST`. A btree index is NULLS LAST ascending, so +# that ordering could not use the (cleanup_failed_at, expires_at) index at all +# and Postgres had to sort every matching expired row before applying the +# limit, work that grows with the backlog. Splitting the query removed it. +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_lanes_sort_by_a_plain_ascending_column(m_objects, m_delete): + lanes = _ttl_lanes(m_objects) + + iv.TTLCleanupView.as_view()(_post("/x")) + + assert lanes.retry.order_by_args == ("cleanup_failed_at",) + assert lanes.fresh.order_by_args == ("expires_at",) + + +# (6c) with nothing to retry, the fresh lane still gets the WHOLE batch -- the +# reserve is a cap on the retry lane, not a permanent tax on the normal case. +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_fresh_lane_gets_the_whole_batch_when_nothing_is_retrying( + m_objects, m_delete +): + lanes = _ttl_lanes(m_objects) + + iv.TTLCleanupView.as_view()(_post("/x")) + + assert lanes.retry.slice == slice(None, 100, None) + assert lanes.fresh.slice == slice(None, 500, None) + + +# (6d) THE fix for the second starvation direction. With the retry lane full, +# the fresh lane shrinks by exactly that many, so the two together never exceed +# the batch cap -- and crucially the retry lane is served FIRST, so a steady +# stream of new expirations can no longer push retries out of every batch and +# leave their files in storage indefinitely. Both orderings tried before this +# starved one side: oldest-expiry-first starved fresh work, NULLS FIRST starved +# retries. +@mock.patch.object(maintenance, "delete_job_files", return_value=["input_ref"]) +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_reserves_capacity_for_retries_under_a_flood_of_new_expiries( + m_objects, m_delete +): + retries = [ + AgentKVJob(id=uuid.uuid4(), input_ref=f"r{i}", cleanup_failed_at=timezone.now()) + for i in range(100) + ] + fresh = [AgentKVJob(id=uuid.uuid4(), input_ref=f"f{i}") for i in range(500)] + lanes = _ttl_lanes(m_objects, retries=retries, fresh=fresh) + + resp = iv.TTLCleanupView.as_view()(_post("/x")) + + assert lanes.retry.slice == slice(None, 100, None) + assert lanes.fresh.slice == slice(None, 400, None) + # Every retry was attempted even though 500 fresh rows were queued behind + # them, and the batch cap still held: 100 + 400 == 500. + assert m_delete.call_count == 500 + assert resp.data == {"cleaned": 0, "retained": 500} + + +# (7) files are deleted BEFORE the refs are blanked -- order matters: a +# delete failure must not blank a ref pointing at a file that's still there. +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_deletes_files_before_blanking_refs(m_objects, m_delete): + job_id = uuid.uuid4() + job = AgentKVJob( + id=job_id, input_ref="org/o/j/input.pdf", result_ref="org/o/j/result.json" + ) + m_delete.return_value = ["input_ref", "result_ref"] + m_qs = m_objects.filter.return_value + _ttl_lanes(m_objects, fresh=[job]) + + manager = mock.Mock() + manager.attach_mock(m_delete, "delete_job_files") + manager.attach_mock(m_qs.update, "update") + + resp = iv.TTLCleanupView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"cleaned": 1, "retained": 0} + assert [c[0] for c in manager.mock_calls] == ["delete_job_files", "update"] + m_delete.assert_called_once_with(job) + + +# (8) both refs are blanked via `.update()` (not `job.save()`), targeting +# exactly this job's row, and the row itself is left in place (no .delete() +# call is ever made on the queryset). +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_blanks_both_refs_for_the_job_row(m_objects, m_delete): + job_id = uuid.uuid4() + job = AgentKVJob(id=job_id, input_ref="org/o/j/input.pdf", result_ref="") + m_delete.return_value = ["input_ref", "result_ref"] + m_qs = m_objects.filter.return_value + _ttl_lanes(m_objects, fresh=[job]) + + iv.TTLCleanupView.as_view()(_post("/x")) + + assert m_objects.filter.call_args_list[-1].kwargs == {"id": job_id} + m_qs.update.assert_called_once_with( + input_ref="", result_ref="", cleanup_failed_at=None + ) + assert not m_qs.delete.called + + +# (9) `cleaned` counts jobs actually processed this call, across multiple +# candidates. +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_returns_count_of_jobs_cleaned(m_objects, m_delete): + job1 = AgentKVJob(id=uuid.uuid4(), input_ref="a", result_ref="") + job2 = AgentKVJob(id=uuid.uuid4(), input_ref="", result_ref="b") + m_delete.return_value = ["input_ref", "result_ref"] + m_qs = m_objects.filter.return_value + _ttl_lanes( + m_objects, + fresh=[ + job1, + job2, + ], + ) + + resp = iv.TTLCleanupView.as_view()(_post("/x")) + + assert resp.data == {"cleaned": 2, "retained": 0} + assert m_delete.call_count == 2 + assert m_qs.update.call_count == 2 + + +# (10) no candidates -> {"cleaned": 0}, nothing deleted or updated. This is +# also the concrete shape of a "second run over the same set" once every +# candidate from the first run has had its refs blanked: the (mocked) +# candidate query simply returns nothing. +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_with_no_candidates_is_a_pure_noop(m_objects, m_delete): + m_qs = m_objects.filter.return_value + _ttl_lanes(m_objects, fresh=[]) + + resp = iv.TTLCleanupView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data == {"cleaned": 0, "retained": 0} + assert not m_delete.called + assert not m_qs.update.called + + +# (10b) THE case test (7)'s comment claims ("a delete failure must not blank a +# ref pointing at a file that's still there") but which nothing actually +# asserted until the Greptile review: test (7) only pinned the ORDER of the two +# calls, and ordering is irrelevant when the update blanks both refs regardless +# of what the delete returned. Before the fix this test fails -- `update` is +# called with both refs blanked and the still-present result file loses its only +# handle, since the candidate query below matches only rows with a non-blank +# ref. +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_keeps_the_ref_whose_file_delete_failed(m_objects, m_delete): + job_id = uuid.uuid4() + job = AgentKVJob( + id=job_id, input_ref="org/o/j/input.pdf", result_ref="org/o/j/result.json" + ) + # Input gone, result delete raised inside delete_job_files. + m_delete.return_value = ["input_ref"] + m_qs = m_objects.filter.return_value + _ttl_lanes(m_objects, fresh=[job]) + + resp = iv.TTLCleanupView.as_view()(_post("/x")) + + # Only the confirmed-gone ref is blanked; result_ref is NOT in the update, + # so the row still matches `result_ref__gt=""` and the next pass retries. + (), kwargs = m_qs.update.call_args + assert kwargs["input_ref"] == "" + assert "result_ref" not in kwargs + # Stamped so the nulls-first ordering pushes this row behind all never-failed + # work on the next tick -- retried, but unable to block the backlog. + assert kwargs["cleanup_failed_at"] is not None + # Not counted as cleaned -- it is unfinished, and `retained` is what makes a + # permanently-failing backlog visible instead of silently draining to zero. + assert resp.data == {"cleaned": 0, "retained": 1} + + +# (10c) nothing confirmed gone -> no ref is blanked, but the failure is still +# recorded. Skipping the write entirely would leave cleanup_failed_at NULL, and +# a NULL sorts FIRST under the candidate ordering -- so the row would hold the +# head of every batch indefinitely, which is the starvation this ordering +# exists to prevent. +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_blanks_no_ref_but_still_stamps_the_failure(m_objects, m_delete): + job = AgentKVJob( + id=uuid.uuid4(), input_ref="org/o/j/input.pdf", result_ref="org/o/j/r.json" + ) + m_delete.return_value = [] + m_qs = m_objects.filter.return_value + _ttl_lanes(m_objects, fresh=[job]) + + resp = iv.TTLCleanupView.as_view()(_post("/x")) + + # No ref is blanked -- but the failure IS stamped, or the row would sort + # nulls-first forever and keep its place at the head of every batch. + (), kwargs = m_qs.update.call_args + assert kwargs == {"cleanup_failed_at": mock.ANY} + assert kwargs["cleanup_failed_at"] is not None + assert resp.data == {"cleaned": 0, "retained": 1} + + +# (10d) a row that failed before and succeeds now must have its stale failure +# marker cleared. It drops out of the candidate filter anyway (both refs blank), +# so this matters for the audit trail and for anything reading +# cleanup_failed_at as "currently failing" rather than "failed once". +@mock.patch.object(maintenance, "delete_job_files") +@mock.patch.object(AgentKVJob, "objects") +def test_ttl_cleanup_clears_a_stale_failure_marker_on_success(m_objects, m_delete): + job = AgentKVJob( + id=uuid.uuid4(), + input_ref="org/o/j/input.pdf", + result_ref="org/o/j/result.json", + cleanup_failed_at=timezone.now(), + ) + m_delete.return_value = ["input_ref", "result_ref"] + m_qs = m_objects.filter.return_value + _ttl_lanes(m_objects, fresh=[job]) + + resp = iv.TTLCleanupView.as_view()(_post("/x")) + + m_qs.update.assert_called_once_with( + input_ref="", result_ref="", cleanup_failed_at=None + ) + assert resp.data == {"cleaned": 1, "retained": 0} + + +# --------------------------------------------------------------------------- +# URL wiring +# --------------------------------------------------------------------------- + + +# (11) regression pin for the frozen paths (spec Interfaces block): the +# PG-scheduler/reaper periodic mechanism calls these exact URLs, so a +# dropped/renamed include in internal_base_urls.py must fail loudly here +# rather than 404 in prod. +def test_frozen_sweep_and_ttl_cleanup_urls_resolve_to_the_right_views(): + from django.urls import resolve + + sweep = resolve("/internal/v1/agent-kv/sweep/") + assert sweep.func.cls is iv.SweepView + + ttl_cleanup = resolve("/internal/v1/agent-kv/ttl-cleanup/") + assert ttl_cleanup.func.cls is iv.TTLCleanupView diff --git a/backend/agent_kv/tests/test_table_extractor_routing.py b/backend/agent_kv/tests/test_table_extractor_routing.py new file mode 100644 index 0000000000..44c012f5b9 --- /dev/null +++ b/backend/agent_kv/tests/test_table_extractor_routing.py @@ -0,0 +1,241 @@ +"""Routing a `table` extractor entry to the table executor. + +The wire format is extractor-scoped (§7.0): `extractors: [{name, keys, options}]`. +v1 accepts exactly one entry, but WHICH one is now a choice, so the job row has +to record it -- status and result used to key everything under the hardcoded +`kv` name, which would have filed a table job's output under the wrong +extractor. +""" + +import json +import os +import uuid +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from agent_kv.constants import ( # noqa: E402 + EXTRACTOR_ROUTES, + STAGE_NAMES_BY_EXTRACTOR, + TABLE_EXTRACTOR_NAME, + V1_EXTRACTOR_NAME, +) +from agent_kv.execution_serializers import ( # noqa: E402 + _OPTIONS_SERIALIZERS, + SUPPORTED_EXTRACTORS, + ExtractorSerializer, +) +from agent_kv.models import AgentKVJob, JobStatus # noqa: E402 + + +def test_table_is_the_only_supported_extractor(): + """`kv` is out of EXTRACTOR_ROUTES because this deployment ships no + `agentic_kv` plugin, so nothing drains `celery_executor_agentic_kv`. + SUPPORTED_EXTRACTORS is derived from that table, so the omission is what + turns a `kv` submit into a 400 rather than a 202 for a job that never runs. + """ + assert set(SUPPORTED_EXTRACTORS) == {TABLE_EXTRACTOR_NAME} + assert V1_EXTRACTOR_NAME not in EXTRACTOR_ROUTES + + +def test_every_supported_extractor_has_a_route_stage_list_and_options_serializer(): + """A supported extractor with no route dispatches nowhere; with no stage + list its progress is recorded and then filtered out of the status + document; with no options serializer, a submit for it raises an + uncaught KeyError at `_OPTIONS_SERIALIZERS[data["name"]]` instead of a + validation error -- `validate_name` already passed it, since + `SUPPORTED_EXTRACTORS` is derived from `EXTRACTOR_ROUTES`. + """ + for name in SUPPORTED_EXTRACTORS: + assert name in EXTRACTOR_ROUTES, name + assert name in STAGE_NAMES_BY_EXTRACTOR, name + assert name in _OPTIONS_SERIALIZERS, name + + +def test_the_table_route_targets_the_existing_executor(): + """R1: the queue is derived from the executor name, and + celery_executor_agentic_table is already wired. A new executor name here + would be accepted, dispatch silently, and never drain. + """ + executor, operation = EXTRACTOR_ROUTES[TABLE_EXTRACTOR_NAME] + assert executor == "agentic_table" + assert operation == "table_extract_api" + + +def test_a_table_entry_validates_with_its_own_options(): + data = { + "name": "table", + "keys": {"target_table": "Rent rolls"}, + "options": {"instructions": "skip totals rows"}, + } + s = ExtractorSerializer(data=data) + assert s.is_valid(), s.errors + assert s.validated_data["options"]["instructions"] == "skip totals rows" + + +def test_a_table_entry_requires_a_target_table(): + s = ExtractorSerializer(data={"name": "table", "keys": {}, "options": {}}) + assert not s.is_valid() + assert "target_table" in json.dumps(s.errors) + + +def test_kv_options_are_rejected_on_a_table_entry(): + """The whole point of per-extractor options: an option aimed at the wrong + extractor must not be silently dropped. + """ + s = ExtractorSerializer( + data={"name": "table", "keys": {"target_table": "T"}, "options": {"qa": True}} + ) + assert not s.is_valid() + assert "qa" in json.dumps(s.errors) + + +def test_a_kv_entry_is_refused_by_name(): + """Options scoping is moot for `kv` here: `validate_name` refuses the entry + before any options validator runs. What matters is that the refusal names + the extractor, so a caller sending a KV payload to this deployment learns + why rather than seeing a schema complaint. + """ + s = ExtractorSerializer( + data={ + "name": "kv", + "keys": {"total": {"description": "d"}}, + "options": {"qa": False}, + } + ) + assert not s.is_valid() + assert "unknown extractor" in json.dumps(s.errors) + assert "kv" in json.dumps(s.errors) + + +def _job(extractor=TABLE_EXTRACTOR_NAME, **overrides): + """An unsaved job with an unsaved org -- this suite's established pattern. + + `test_dispatch.py` builds jobs exactly this way and the whole `agent_kv` + suite touches no database (there is no `django_db` marker anywhere in it). + Assigning an unsaved `Organization` with an explicit PK caches it on the + job, so `job.organization` never hits the DB, and the slug is deliberately + different from the PK so a test can tell which one the code reached for. + """ + from account_v2.models import Organization + + job = AgentKVJob(id=uuid.uuid4(), input_ref="org/o/agent_kv/j/input.pdf") + job.organization = Organization(id=7, organization_id="org_slug_1") + job.extractor = extractor + job.pages_total = 3 + for key, value in overrides.items(): + setattr(job, key, value) + return job + + +def test_the_kv_route_constants_survive_for_re_enablement(): + """`kv` is unroutable, not deleted. The executor/operation pair, the stage + list and the options serializer all stay so the branch carrying the engine + re-enables the extractor by restoring one dict entry -- not by merging + content back into the files it rewrites most. + """ + from agent_kv.constants import ( + EXECUTOR_NAME, + OPERATION_KV_EXTRACT, + STAGE_NAMES_BY_EXTRACTOR, + ) + from agent_kv.execution_serializers import _OPTIONS_SERIALIZERS + + assert EXECUTOR_NAME == "agentic_kv" + assert OPERATION_KV_EXTRACT == "kv_extract" + assert V1_EXTRACTOR_NAME in STAGE_NAMES_BY_EXTRACTOR + assert V1_EXTRACTOR_NAME in _OPTIONS_SERIALIZERS + + +def test_the_status_document_keys_stages_by_the_extractor_that_ran(): + from agent_kv.execution_views import _status_document + + job = _job( + stage="table_extraction", + stages={"table_extraction": {"status": "done", "seconds": 12.5}}, + ) + doc = _status_document(job) + + assert TABLE_EXTRACTOR_NAME in doc["extractors"] + assert V1_EXTRACTOR_NAME not in doc["extractors"] + # R7: recorded AND visible. StageReportView persists any stage name the + # executor sends, but _status_document filters through the extractor's own + # list -- with the KV list this array would come back empty. + assert doc["extractors"][TABLE_EXTRACTOR_NAME]["stages"] == [ + {"name": "table_extraction", "status": "done", "seconds": 12.5} + ] + + +def test_a_kv_job_still_reports_its_kv_stages(): + from agent_kv.execution_views import _status_document + + job = _job( + extractor=V1_EXTRACTOR_NAME, + stage="extraction", + stages={ + "document_processing": {"status": "done"}, + "extraction": {"status": "running"}, + }, + ) + doc = _status_document(job) + + names = [s["name"] for s in doc["extractors"][V1_EXTRACTOR_NAME]["stages"]] + assert names == ["document_processing", "extraction"] + + +def test_the_result_payload_keys_by_the_extractor_that_ran(): + from agent_kv.execution_views_result import result_payload + + job = _job( + status=JobStatus.COMPLETED, + result_ref="ref.json", + usage_summary={"pages": 3}, + ) + with mock.patch( + "agent_kv.execution_views_result.read_result", + return_value={"tables": [{"unit": "A1"}]}, + ): + payload = result_payload(job) + + assert payload["success"] is True + assert payload["status"] == "completed" + assert payload["extractors"][TABLE_EXTRACTOR_NAME] == {"tables": [{"unit": "A1"}]} + assert payload["usage_summary"]["by_extractor"][TABLE_EXTRACTOR_NAME] == {"pages": 3} + assert V1_EXTRACTOR_NAME not in payload["extractors"] + + +def test_the_extractor_column_defaults_to_kv(): + """The migration's default is the historical truth, not a guess: before + this column existed the API accepted exactly one extractor, always `kv`. + """ + assert AgentKVJob().extractor == V1_EXTRACTOR_NAME + + +def test_a_job_whose_extractor_has_no_stage_list_reports_no_stages(caplog): + """A retired extractor name with surviving rows must not 500 `GET status`. + + `_status_document` used to subscript `STAGE_NAMES_BY_EXTRACTOR`, so such a + row raised `KeyError` on status while `GET result` kept working -- the + result payload keys by `job.extractor` without consulting that table. An + empty stage array is the honest answer for an extractor this build no + longer knows how to describe. + """ + from agent_kv.execution_views import _status_document + + job = _job( + extractor="retired_extractor", + stage="something", + stages={"something": {"status": "done"}}, + ) + + with caplog.at_level("WARNING", logger="agent_kv.execution_views"): + doc = _status_document(job) + + assert doc["extractors"]["retired_extractor"]["stages"] == [] + assert doc["status"] == job.status.lower() + assert "retired_extractor" in caplog.text diff --git a/backend/agent_kv/tests/test_validate_view.py b/backend/agent_kv/tests/test_validate_view.py new file mode 100644 index 0000000000..3d6b5ffcba --- /dev/null +++ b/backend/agent_kv/tests/test_validate_view.py @@ -0,0 +1,130 @@ +import json +import os +import uuid +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from rest_framework.test import APIRequestFactory # noqa: E402 + +from agent_kv import execution_views as ev # noqa: E402 +from agent_kv.models import AgentKVKey # noqa: E402 +from agent_kv.tests._factories import kv_key # noqa: E402 + + +def _authed(method="post", path="/agent-kv/validate", data=None): + if data is None: + data = {} + req = getattr(APIRequestFactory(), method)( + path, + json.dumps(data) if data else "", + content_type="application/json", + ) + req.META["HTTP_AUTHORIZATION"] = "Bearer 123e4567-e89b-12d3-a456-426614174001" + return req + + +# --------------------------------------------------------------------------- +# (1) Valid schema -> returns valid:true with correct counts +# --------------------------------------------------------------------------- +# These three mock `check_key_rate` explicitly, like test_submit_view.py does. +# They previously relied on the limiter's ambient behaviour: it failed OPEN on +# any Redis error, so with no Redis in the unit lane it returned True and the +# tests passed for a reason unrelated to what they assert. The limiter now +# fails CLOSED (a Sentinel failover used to remove the concurrency and rate +# ceilings at once while the API kept accepting billable work), so an +# unreachable Redis is a 429 and these became 429 == 200. The mock is the right +# fix either way: these are schema-validation tests, and the rate limiter +# should not be in their path at all. +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(AgentKVKey, "objects") +def test_validate_valid_schema_returns_counts(m_keys, m_rate): + m_keys.get.return_value = kv_key() + schema = { + "quotation_number": {"description": "The quote number", "required": True}, + "customer": {"name": {"description": "Bill-to name"}}, + "line_items": { + "description": "One row per line", + "_key": "sku", + "_array": { + "sku": {"description": "SKU"}, + "total": {"description": "Line total", "format": "currency"}, + }, + }, + "_constraints": ["count('line_items') >= 1"], + } + req = _authed(data={"keys": schema}) + + resp = ev.ValidateView.as_view()(req) + + assert resp.status_code == 200 + assert resp.data["valid"] is True + assert "leaves" in resp.data + assert "arrays" in resp.data + assert "constraints" in resp.data + assert isinstance(resp.data["leaves"], int) + assert isinstance(resp.data["arrays"], int) + assert isinstance(resp.data["constraints"], int) + + +# --------------------------------------------------------------------------- +# (2) Invalid schema -> returns valid:false with error message verbatim +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(AgentKVKey, "objects") +def test_validate_invalid_schema_returns_error(m_keys, m_rate): + m_keys.get.return_value = kv_key() + # Pass a non-dict as top-level schema + req = _authed(data={"keys": "not a dict"}) + + resp = ev.ValidateView.as_view()(req) + + assert resp.status_code == 200 + assert resp.data["valid"] is False + assert "error" in resp.data + assert resp.data["error"] == "Top-level key schema must be a JSON object" + + +# --------------------------------------------------------------------------- +# (3) No key (no Authorization header) -> 403 Forbidden +# --------------------------------------------------------------------------- +def test_validate_no_key_returns_403(): + req = APIRequestFactory().post("/agent-kv/validate", data={}) + resp = ev.ValidateView.as_view()(req) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# (4) Over rate limit -> 429 +# --------------------------------------------------------------------------- +@mock.patch("agent_kv.execution_views.check_key_rate", return_value=False) +@mock.patch.object(AgentKVKey, "objects") +def test_validate_over_rate_limit_returns_429(m_keys, m_check_rate): + # A concrete id, because the rate limiter is keyed on it. + key = kv_key(id=uuid.uuid4()) + m_keys.get.return_value = key + req = _authed(data={"keys": {}}) + + resp = ev.ValidateView.as_view()(req) + + assert resp.status_code == 429 + + +# --------------------------------------------------------------------------- +# (5) Missing 'keys' in request body -> 400 +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(AgentKVKey, "objects") +def test_validate_missing_keys_returns_400(m_keys, m_rate): + m_keys.get.return_value = kv_key() + req = _authed(data={}) + + resp = ev.ValidateView.as_view()(req) + + assert resp.status_code == 400 + assert resp.data["detail"] == "body must include 'keys'" diff --git a/backend/agent_kv/urls.py b/backend/agent_kv/urls.py new file mode 100644 index 0000000000..206b7f0d95 --- /dev/null +++ b/backend/agent_kv/urls.py @@ -0,0 +1,23 @@ +from django.urls import path + +from agent_kv.views import AgentKVKeyViewSet + +urlpatterns = [ + path( + "keys/", + AgentKVKeyViewSet.as_view({"get": "list", "post": "create"}), + name="agent_kv_key_list", + ), + path( + "keys//", + AgentKVKeyViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="agent_kv_key_detail", + ), + path( + "keys//rotate/", + AgentKVKeyViewSet.as_view({"post": "rotate"}), + name="agent_kv_key_rotate", + ), +] diff --git a/backend/agent_kv/views.py b/backend/agent_kv/views.py new file mode 100644 index 0000000000..466a4c7eee --- /dev/null +++ b/backend/agent_kv/views.py @@ -0,0 +1,55 @@ +import uuid + +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.serializers import BaseSerializer + +from agent_kv.models import AgentKVKey +from agent_kv.permissions import IsOrganizationAdmin +from agent_kv.serializers import AgentKVKeySerializer, AgentKVKeyWriteSerializer + + +class AgentKVKeyViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated, IsOrganizationAdmin] + + def get_queryset(self): + # No explicit org filter here — the global ``OrganizationFilterBackend`` + # (in DEFAULT_FILTER_BACKENDS) scopes every DRF operation by the + # current org's ``organization`` FK on this model. + return AgentKVKey.objects.all() + + def get_serializer_class(self): + if self.action in ("create", "update", "partial_update"): + return AgentKVKeyWriteSerializer + return AgentKVKeySerializer + + def perform_create(self, serializer: BaseSerializer) -> None: + serializer.save(created_by=self.request.user) + + def create(self, request, *args, **kwargs): + """Respond with the read serializer, not the write one (DRF's default). + + ``AgentKVKeyWriteSerializer`` (used to validate the request, per + ``get_serializer_class``) only carries ``name``/``description``/ + ``is_active`` -- the standard ``CreateModelMixin.create`` would echo + that same serializer back, meaning the caller who just created a key + would never see ``id`` or the raw ``key`` value itself. Both are + server-generated and this is the only response that will ever carry + the plaintext key (list/retrieve return it too today, but rotate is + the only other place a caller can *see* a fresh one) -- mirrors + ``GlobalApiDeploymentKeyViewSet.create``. + """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + response_serializer = AgentKVKeySerializer(serializer.instance) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["post"]) + def rotate(self, request, pk=None): + key_obj = self.get_object() + key_obj.key = uuid.uuid4() + key_obj.save(update_fields=["key", "modified_at"]) + return Response(AgentKVKeySerializer(key_obj).data) diff --git a/backend/backend/base_urls.py b/backend/backend/base_urls.py index 0b763e3c59..30d5d790da 100644 --- a/backend/backend/base_urls.py +++ b/backend/backend/base_urls.py @@ -37,6 +37,8 @@ f"{settings.API_DEPLOYMENT_PATH_PREFIX}/pipeline/", include("pipeline_v2.public_api_urls"), ), + # Agent-KV product API (views own auth; prefix is middleware-whitelisted) + path(f"{settings.AGENT_KV_PATH_PREFIX}/", include("agent_kv.execution_urls")), path("", include("health.urls")), # Internal API for worker communication path("internal/", include("backend.internal_base_urls")), diff --git a/backend/backend/internal_base_urls.py b/backend/backend/internal_base_urls.py index 30d4c8df22..ab184c8fb0 100644 --- a/backend/backend/internal_base_urls.py +++ b/backend/backend/internal_base_urls.py @@ -246,6 +246,12 @@ def test_middleware_debug(request): include("api_v2.internal_urls"), name="api_deployments_internal", ), + # Agent-KV stage-report and finalize APIs for the cloud executor + path( + "v1/agent-kv/", + include("agent_kv.internal_urls"), + name="agent_kv_internal", + ), # Platform configuration and settings APIs path( "v1/platform-settings/", diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index aa03b577ae..f90416969c 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -204,6 +204,48 @@ def get_required_setting(setting_key: str, default: str | None = None) -> str | os.environ.get("API_DEPLOYMENT_RATE_LIMIT_LOCK_BLOCKING_TIMEOUT", 5) ) +# Agent-KV API +AGENT_KV_PATH_PREFIX = os.environ.get("AGENT_KV_PATH_PREFIX", "agent-kv") +# Bucket-rooted object-store root for staged inputs/results, mirroring +# WORKFLOW_EXECUTION_DIR_PREFIX / API_EXECUTION_DIR_PREFIX: the FIRST segment is +# the bucket (s3fs/gcsfs treat it that way), so it must already exist. MUST match +# the cloud executor's AGENT_KV_STORAGE_DIR_PREFIX -- the executor keys its OCR +# cache under the same root. +AGENT_KV_STORAGE_DIR_PREFIX = ( + os.environ.get("AGENT_KV_STORAGE_DIR_PREFIX", "unstract/agent_kv").strip().strip("/") + or "unstract/agent_kv" +) # same normalisation as the cloud executor's reader (whitespace, edge slashes) +AGENT_KV_MAX_FILE_SIZE_MB = int(os.environ.get("AGENT_KV_MAX_FILE_SIZE_MB", 50)) +AGENT_KV_MAX_PAGES = int(os.environ.get("AGENT_KV_MAX_PAGES", 100)) +AGENT_KV_MAX_CALCULATIONS_BYTES = int( + os.environ.get("AGENT_KV_MAX_CALCULATIONS_BYTES", 20_000) +) +AGENT_KV_MAX_SCHEMA_BYTES = int(os.environ.get("AGENT_KV_MAX_SCHEMA_BYTES", 262_144)) +AGENT_KV_RESULT_TTL_DAYS = int(os.environ.get("AGENT_KV_RESULT_TTL_DAYS", 7)) +AGENT_KV_MAX_TIMEOUT_SECONDS = int(os.environ.get("AGENT_KV_MAX_TIMEOUT_SECONDS", 300)) +AGENT_KV_CONCURRENT_LIMIT = int(os.environ.get("AGENT_KV_CONCURRENT_LIMIT", 5)) +AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE = int( + os.environ.get("AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE", 60) +) +# When Redis is unreachable, do requests proceed? Default False = fail CLOSED +# (429). Both Agent-KV limiters used to fail open implicitly, inside an `except` +# block, so a Sentinel failover removed the concurrency ceiling and the per-key +# rate ceiling at once while the API kept accepting billable LLM work. Fail-open +# remains available as a deliberate, visible operational choice. +AGENT_KV_LIMITER_FAIL_OPEN = ( + os.environ.get("AGENT_KV_LIMITER_FAIL_OPEN", "false").lower() == "true" +) +AGENT_KV_SWEEP_GRACE_SECONDS = int(os.environ.get("AGENT_KV_SWEEP_GRACE_SECONDS", 3600)) +AGENT_KV_STUCK_JOB_GRACE_SECONDS = int( + os.environ.get("AGENT_KV_STUCK_JOB_GRACE_SECONDS", 21600) +) +AGENT_KV_CALCULATIONS_ENABLED = ( + os.environ.get("AGENT_KV_CALCULATIONS_ENABLED", "false").lower() == "true" +) +AGENT_KV_STRUCTURED_OUTPUT_ENABLED = ( + os.environ.get("AGENT_KV_STRUCTURED_OUTPUT_ENABLED", "false").lower() == "true" +) + DB_NAME = os.environ.get("DB_NAME", "unstract_db") DB_USER = os.environ.get("DB_USER", "unstract_dev") @@ -454,6 +496,7 @@ def filter(self, record): "dashboard_metrics", "platform_api", "global_api_deployment_key", + "agent_kv", ) TENANT_APPS = [] @@ -978,10 +1021,16 @@ def filter(self, record): "/static", ] WHITELISTED_PATHS = [f"/{PATH_PREFIX}{PATH}" for PATH in WHITELISTED_PATHS_LIST] -# White lists workflow-api-deployment path. This also covers the deployment MCP -# server, which hangs off the same URL and authenticates with the deployment's -# own API key rather than a session. -WHITELISTED_PATHS.append(f"/{API_DEPLOYMENT_PATH_PREFIX}") +WHITELISTED_PATHS.extend( + [ + # workflow-api-deployment. Also covers the deployment MCP server, which + # hangs off the same URL and authenticates with the deployment's own API + # key rather than a session. + f"/{API_DEPLOYMENT_PATH_PREFIX}", + # Agent-KV public API: bearer-key auth happens in the views. + f"/{AGENT_KV_PATH_PREFIX}", + ] +) # Whitelisting health check API WHITELISTED_PATHS.append("/health") diff --git a/backend/backend/urls_v2.py b/backend/backend/urls_v2.py index 4b9636a35c..6e66e9561a 100644 --- a/backend/backend/urls_v2.py +++ b/backend/backend/urls_v2.py @@ -32,6 +32,7 @@ path("", include("feature_flag.urls")), path("workflow/", include("workflow_manager.urls")), path("platform/", include("platform_settings_v2.urls")), + path("agent-kv/", include("agent_kv.urls")), path("api/", include("api_v2.urls")), path("usage/", include("usage_v2.urls")), path("notifications/", include("notification_v2.urls")), diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c1ca5b9ed4..82124f2d5f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "django-filter>=24.3", "httpx>=0.27.0", # Hence required to add all indirect local dependencies too here. + "unstract-agent-kv-schema", "unstract-connectors", "unstract-core", "unstract-filesystem", @@ -90,6 +91,7 @@ deploy = [ ] [tool.uv.sources] +unstract-agent-kv-schema = { path = "../unstract/agent-kv-schema", editable = true } unstract-connectors = { path = "../unstract/connectors", editable = true } unstract-core = { path = "../unstract/core", editable = true } unstract-filesystem = { path = "../unstract/filesystem", editable = true } diff --git a/backend/sample.env b/backend/sample.env index ca5456167d..c32b815e46 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -186,6 +186,33 @@ API_DEPLOYMENT_RATE_LIMIT_LOCK_TIMEOUT=2 # Redis lock blocking timeout (in seconds) - how long to wait to acquire lock API_DEPLOYMENT_RATE_LIMIT_LOCK_BLOCKING_TIMEOUT=5 +# Agent-KV API (backend/backend/settings/base.py) — see docs/agent-kv-api.md +# --------------------------------------------------------------------------- +AGENT_KV_PATH_PREFIX=agent-kv # Public URL prefix (whitelisted past tenant middleware) +AGENT_KV_MAX_FILE_SIZE_MB=50 # Submit-time file size cap (MB) +AGENT_KV_MAX_PAGES=100 # Pre-OCR page cap for PDFs/images; also the post-OCR cap the engine enforces +AGENT_KV_MAX_CALCULATIONS_BYTES=20000 # Byte cap on the optional `calculations` field +AGENT_KV_MAX_SCHEMA_BYTES=262144 # Byte cap on the raw `keys` JSON schema (256 KiB) +AGENT_KV_RESULT_TTL_DAYS=7 # Result/input retention window, stamped at submit time +AGENT_KV_MAX_TIMEOUT_SECONDS=300 # Upper bound on the submit `timeout` (sync-wait) field +AGENT_KV_CONCURRENT_LIMIT=5 # Per-org concurrent in-flight job cap +AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE=60 # Per-key request rate limit (submit + validate) +AGENT_KV_SWEEP_GRACE_SECONDS=3600 # Age before a never-dispatched PENDING job is swept +AGENT_KV_STUCK_JOB_GRACE_SECONDS=21600 # Age (from dispatch) before a stuck DISPATCHED/RUNNING job is force-failed (6h) +AGENT_KV_CALCULATIONS_ENABLED=false # Gates the submit `calculations` field; engine can't run it yet +AGENT_KV_STRUCTURED_OUTPUT_ENABLED=false # Gates the submit `structured_output` field; engine can't run it yet + +# Bucket-rooted object-store root for staged inputs/results +# ({AGENT_KV_STORAGE_DIR_PREFIX}/{org_id}/{job_id}/…), like WORKFLOW_EXECUTION_DIR_PREFIX. +# The FIRST segment is the BUCKET (s3fs/gcsfs treat it so) and must already exist — +# the MinIO dev bootstrap creates `unstract`. MUST match the cloud executor's +# AGENT_KV_STORAGE_DIR_PREFIX: the executor keys its OCR cache under the same root. +AGENT_KV_STORAGE_DIR_PREFIX="unstract/agent_kv" + +# Object storage credentials for those files — same shape as +# WORKFLOW_EXECUTION_FILE_STORAGE_CREDENTIALS; required, no default. +AGENT_KV_FILE_STORAGE_CREDENTIALS='{"provider": "minio", "credentials": {"endpoint_url": "http://unstract-minio:9000", "key": "minio", "secret": "minio123"}}' + # Default user auth credentials DEFAULT_AUTH_USERNAME= DEFAULT_AUTH_PASSWORD= diff --git a/backend/uv.lock b/backend/uv.lock index 5f7fe242e7..3978095974 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -3687,6 +3687,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/13/b688a905653871b10b4ff0403c2ff562c17a0bd50be0d44324f3c85ca48f/ujson-5.12.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:4ea0c490c702c20495e97345acfcf0c2f3153e658ef537ff111929c48b89e10a", size = 45988 }, ] +[[package]] +name = "unstract-agent-kv-schema" +version = "0.1.0" +source = { editable = "../unstract/agent-kv-schema" } + [[package]] name = "unstract-backend" version = "0.0.1" @@ -3720,6 +3725,7 @@ dependencies = [ { name = "social-auth-app-django" }, { name = "social-auth-core" }, { name = "stripe" }, + { name = "unstract-agent-kv-schema" }, { name = "unstract-connectors" }, { name = "unstract-core" }, { name = "unstract-filesystem" }, @@ -3787,6 +3793,7 @@ requires-dist = [ { name = "social-auth-app-django", specifier = "==5.3.0" }, { name = "social-auth-core", specifier = "==4.4.2" }, { name = "stripe", specifier = ">=12.5.1" }, + { name = "unstract-agent-kv-schema", editable = "../unstract/agent-kv-schema" }, { name = "unstract-connectors", editable = "../unstract/connectors" }, { name = "unstract-core", editable = "../unstract/core" }, { name = "unstract-filesystem", editable = "../unstract/filesystem" }, diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index e89ca0e19d..50c8d8fad4 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -74,6 +74,7 @@ services: - traefik.http.routers.backend.rule=Host(`frontend.unstract.localhost`) && (PathPrefix(`/api/v1`) || PathPrefix(`/deployment`) || PathPrefix(`/public`)) - traefik.http.services.backend.loadbalancer.server.port=8000 + # Celery Flower celery-flower: <<: *host_gateway image: unstract/backend:${VERSION} @@ -543,7 +544,13 @@ services: - APPLICATION_NAME=unstract-worker-pg-ide-callback - WORKER_BARRIER_BACKEND=pg - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=ide_callback - - WORKER_PG_QUEUE_CONSUMER_QUEUE=ide_callback + # agent_kv_callback carries the Agent-KV terminal callbacks (finalize: + # persist the result, delete the staged input, release the concurrency + # slot, fire the webhook). Without it a job runs to success and then + # sits in RUNNING forever -- the executor's continuation is enqueued + # and nothing drains it. The Celery ide_callback worker has carried + # both queues since the callbacks landed; its PG twin did not. + - WORKER_PG_QUEUE_CONSUMER_QUEUE=ide_callback,agent_kv_callback - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 - WORKER_PG_QUEUE_CONSUMER_CONCURRENCY=${PG_IDE_CALLBACK_CONCURRENCY:-2} # Log publisher — see the note on the backend service (UN-4046). diff --git a/docker/sample.env b/docker/sample.env index 47905773da..39155d8ba7 100644 --- a/docker/sample.env +++ b/docker/sample.env @@ -99,6 +99,55 @@ SCHEDULER_TASK_TIME_LIMIT=1800 # Scheduler timeout (30 minutes) LOG_CONSUMER_AUTOSCALE=2,1 # Max 2, min 1 workers LOG_CONSUMER_TASK_TIME_LIMIT=600 # Log processing timeout (10 minutes) +# IDE Callback Worker (worker-ide-callback) — Agent-KV terminal callbacks +# --------------------------------------------------------------------------- +# The ide_callback worker also owns agent_kv_complete/agent_kv_error +# (Agent-KV job finalize callbacks, spec §5.3) on their own queue, +# `agent_kv_callback`, alongside its existing `ide_callback` queue. +# unstract/backend's agent_kv.dispatch dispatches those tasks straight to +# that queue name, so worker-ide-callback must consume both queues or +# Agent-KV jobs will dispatch but never finalize. +# +# NOTE: unlike CALLBACK/FILE_PROCESSING/etc. above, this worker's queue list +# is NOT overridable from this file — there is no CELERY_QUEUES_IDE_CALLBACK +# var wired into workers/run-worker-docker.sh's queue resolution. The queue +# list is a hardcoded default in that script (WORKER_QUEUES["ide_callback"]), +# already set to "ide_callback,agent_kv_callback" there. Nothing to set here; +# if you ever need a different set for this worker, edit that script's +# default directly (or run-worker.sh's equivalent for non-docker dev runs) — +# there's no env-var lever for it today. + +# Agent-KV API (backend/backend/settings/base.py) — see docs/agent-kv-api.md +# --------------------------------------------------------------------------- +# Django app settings for the Agent-KV extraction API (unstract/backend's +# `agent_kv` app). Consumed by the Django backend process, not by +# docker-compose itself — mirror these into backend/.env (backend/sample.env) +# for a real deployment; listed here per the Agent-KV env-sample convention. +AGENT_KV_PATH_PREFIX=agent-kv # Public URL prefix (whitelisted past tenant middleware) +AGENT_KV_MAX_FILE_SIZE_MB=50 # Submit-time file size cap (MB) +AGENT_KV_MAX_PAGES=100 # Pre-OCR page cap for PDFs/images +AGENT_KV_MAX_CALCULATIONS_BYTES=20000 # Byte cap on the optional `calculations` field +AGENT_KV_MAX_SCHEMA_BYTES=262144 # Byte cap on the raw `keys` JSON schema (256 KiB) +AGENT_KV_RESULT_TTL_DAYS=7 # Result/input retention window, stamped at submit time +AGENT_KV_MAX_TIMEOUT_SECONDS=300 # Upper bound on the submit `timeout` (sync-wait) field +AGENT_KV_CONCURRENT_LIMIT=5 # Per-org concurrent in-flight job cap +AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE=60 # Per-key request rate limit (submit + validate) +AGENT_KV_SWEEP_GRACE_SECONDS=3600 # Age before a never-dispatched PENDING job is swept +AGENT_KV_STUCK_JOB_GRACE_SECONDS=21600 # Age (from dispatch) before a stuck DISPATCHED/RUNNING job is force-failed (6h) +AGENT_KV_CALCULATIONS_ENABLED=false # Gates the submit `calculations` field; engine can't run it yet +AGENT_KV_STRUCTURED_OUTPUT_ENABLED=false # Gates the submit `structured_output` field; engine can't run it yet + +# Bucket-rooted object-store root for staged inputs/results +# ({AGENT_KV_STORAGE_DIR_PREFIX}/{org_id}/{job_id}/…), like WORKFLOW_EXECUTION_DIR_PREFIX. +# The FIRST segment is the BUCKET (s3fs/gcsfs treat it so) and must already exist — +# the MinIO dev bootstrap creates `unstract`. MUST match the cloud executor's +# AGENT_KV_STORAGE_DIR_PREFIX: the executor keys its OCR cache under the same root. +AGENT_KV_STORAGE_DIR_PREFIX="unstract/agent_kv" + +# Object storage credentials for those files — same shape as +# WORKFLOW_EXECUTION_FILE_STORAGE_CREDENTIALS; required, no default. +AGENT_KV_FILE_STORAGE_CREDENTIALS='{"provider": "minio", "credentials": {"endpoint_url": "http://unstract-minio:9000", "key": "minio", "secret": "minio123"}}' + # Worker Circuit Breaker Settings CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 CIRCUIT_BREAKER_RECOVERY_TIMEOUT=60 diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md new file mode 100644 index 0000000000..a3d17308af --- /dev/null +++ b/docs/agent-kv-api.md @@ -0,0 +1,1011 @@ +# Agent-KV API + +Agent-KV is Unstract's agentic key-value extraction product: upload a document plus a +`keys.json`-style extraction schema, get back a job id, poll a verbose agent-centric +status document, and fetch a rich result (record + per-field QA/challenger audit +trail). + +This document describes the API **as built in this repo** — the OSS scaffold (routes, +validation, dispatch, job lifecycle, key management). The document, `keys.json` schema, +and OCR/LLM extraction pipeline itself are described only insofar as they shape the +contract; the extraction engine ships as a cloud executor plugin (see +[§11, 501 behavior](#11-when-the-engine-is-unavailable-501-behavior) below). + +Design background: `docs/superpowers/specs/2026-08-28-agent-kv-api-design.md` (referred +to as "the spec" throughout this document; section numbers below are spec section +numbers). This document records what shipped, including a handful of places where the +implementation is more specific than — or diverges from — the spec's sketch. Those are +called out explicitly rather than silently reconciled. + +## 1. Authentication + +There are two independent auth schemes on two different URL prefixes: + +| Surface | Prefix | Auth | +|---|---|---| +| Public execution API (submit/status/result/cancel/validate) | `/agent-kv/…` (top-level, not under the tenant subfolder) | `Authorization: Bearer ` — a dedicated per-organization key, validated by the view itself | +| Key management (create/list/rotate/revoke) | `{PATH_PREFIX}/unstract/…/agent-kv/keys/…` (tenant-scoped, alongside Platform API key management) | Session auth, `IsOrganizationAdmin` only | + +The execution prefix (`AGENT_KV_PATH_PREFIX`, default `agent-kv`) sits **outside** the +tenant-subfolder/organization-middleware chain — it is added to `WHITELISTED_PATHS` in +`backend/backend/settings/base.py` — because a caller authenticates with an API key, not +a session/org header. Every view under it therefore owns its own auth via the +`AgentKVKeyValidator.validate_api_key` decorator (`backend/agent_kv/key_validator.py`), +which: + +1. Requires `Authorization: Bearer ` where `` is a UUID. +2. Looks up an active (`is_active=True`) `AgentKVKey` by that UUID. +3. Injects the resolved key object as `agent_kv_key` into the view kwargs, which every + subsequent lookup filters by (`organization_id=agent_kv_key.organization_id`) — this + is what makes a job in another org indistinguishable from an unknown job id (both + 404, see [Status](#4-status--get-agent-kvjob_id)). + +**Drift from the spec:** spec §6.1 describes this as "every endpoint 401s without a +valid key." The implementation returns **403** (`api_v2.exceptions.Forbidden`, reused +as-is rather than adding a new exception class) for both a missing/malformed +`Authorization` header and an unknown/inactive key. This is exercised directly by +`backend/agent_kv/tests/test_auth.py` and `test_job_views.py::test_all_job_views_401_without_key` +(the test name predates the code's actual status; both assert `403`). Document the code: +**403, not 401**, for any auth failure on the public prefix. + +Key management (`backend/agent_kv/views.py`, `AgentKVKeyViewSet`) is a standard DRF +`ModelViewSet` scoped by the global `OrganizationFilterBackend` and gated by +`IsOrganizationAdmin` (`backend/agent_kv/permissions.py`). Fields returned: +`id, name, description, key, is_active, created_at`; write-side fields are +`name, description, is_active` only (`key` and `id` are server-generated). Routes +(`backend/agent_kv/urls.py`, mounted at `agent-kv/` under +`backend/backend/urls_v2.py`): + +``` +GET/POST .../agent-kv/keys/ +GET/PATCH/DELETE .../agent-kv/keys// +POST .../agent-kv/keys//rotate/ # replaces `key` with a new UUID +``` + +Key storage follows the same conventions as Platform API key management (D5) — +plaintext-at-rest UUID keys, not hashed-at-rest; that hardening is explicitly deferred, +not a v1 gap unique to this feature. + +## 2. The extraction schema (`keys` field) + +The `keys` submit field — spec-called "keys.json" — is a JSON object describing what to +extract. It compiles through `unstract.agent_kv_schema.compile_schema` +(`unstract/agent-kv-schema/src/unstract/agent_kv_schema/`), the **single schema compiler +in the system**: the OSS API validates with it at submit time and at `/validate`, and +the cloud engine imports the same package to execute — the schema language cannot drift +between validation and execution by construction (spec §5.1). + +### Node types + +A schema node is one of three shapes, decided structurally: + +- **Interior node** — every value is itself an object → its keys are child nodes, and + nesting continues (dotted path, e.g. `vendor.address.city`). +- **Leaf node** — no value is an object → defined by scalar attributes: + + | Attribute | Required | Meaning | + |---|---|---| + | `description` | **yes** | Prompted to the extractor; also fed to the breadcrumb-prefixed `effective_description`. | + | `format` | no (default `string`) | `string` \| `number` \| `currency` \| `date` \| `enum:v1,v2,…` \| `regex:` \| any other free-text hint. `number`/`currency` parse via a strip-then-`float()` check; `date` accepts a fixed set of common formats; `enum` matches case-insensitively; `regex` is `re.fullmatch`. | + | `required` | no (default `false`) | Completeness is checked separately from format. | + | `aliases` | no (default `[]`) | Alternate names/labels to help the extractor recognize the field. | + | `multivalued` | no (default `false`) | Value is a comma-separated string; each element is format-checked independently. | + + A node mixing object values and scalar attributes is a **compile-time error** + ("mixes object children and scalar attributes"), as is a leaf with unknown attribute + keys or a missing `description`. + +- **Array node** — has an `_array` key → a flat (non-nested) row schema: + + ```json + "line_items": { + "description": "One row per invoice line", + "_key": "sku", + "_array": { + "sku": {"description": "SKU"}, + "total": {"description": "Line total", "format": "currency"} + } + } + ``` + + `_array`'s value is itself a flat object of leaf columns (row-local paths, not + dotted). `description` is optional context; `_key` (optional) names a column used for + row identity in downstream scoring, otherwise rows are matched positionally. A + nested array inside an array's row schema is rejected at compile time ("not supported + in P8a") — arrays are single-level only in v1. + + `_dedup` (optional, default `true`) controls whether rows identical in **every** + extracted column are collapsed to one. Leave it on for documents where + layout-preserving OCR repeats a row once per replicate column — that is what it + exists for. Set it to `false` when your documents can genuinely contain two + identical rows: + + ```json + "line_items": { + "_dedup": false, + "_array": { + "description": {"description": "Line description"}, + "total": {"description": "Line total", "format": "currency"} + } + } + ``` + + With `_dedup: true` an invoice listing the same item twice comes back with one + row, so the row count — and any `_constraints` aggregate over those rows — is + wrong. The default is `true` only because flipping it would break the OCR corpus + the extractor was built against; it is not the safer value for every document. + Must be a JSON boolean: `"false"` as a string is rejected rather than silently + read as true. + +### Constraints (`_constraints`) + +An optional top-level `_constraints` key: a list of string expressions checked against +the extracted record post-hoc. Each expression is parsed and statically validated +against an AST allowlist before it is ever accepted — no arbitrary code execution: +boolean/comparison/arithmetic operators, plus exactly one string-literal argument to one +of `sum`, `count`, `min`, `max`, `avg` (an array path). Example: +`"count('line_items') >= 1"`. + +### Structural caps (spec §6.1) + +Enforced by `compile_schema` regardless of the byte-size cap below (`SchemaCaps` in +`compile.py`): + +| Cap | Default | +|---|---| +| Max leaves | 200 | +| Max arrays | 20 | +| Max columns per array | 40 | +| Max nesting depth | 6 | +| Max regex pattern length | 200 | +| Max aliases per field | 10 | +| Max description length | 500 | +| Max constraints | 30 | + +These caps are hardcoded in the compiler (not env-configurable); what **is** +env-configurable is the raw byte size of the whole `keys` document +(`AGENT_KV_MAX_SCHEMA_BYTES`) and of `calculations` +(`AGENT_KV_MAX_CALCULATIONS_BYTES`) — see [§13 env reference](#13-environment-reference). + +A schema that fails to compile is rejected with a `400` at submit time — **nothing is +billed** — or, at `/validate`, with a `200` carrying `{"valid": false, "error": ""}` +(compile errors are not 400s there; only a missing `keys` body key is). + +## 3. Submit — `POST /agent-kv/` (multipart) + +Copied verbatim from spec §7.1 and cross-checked field-by-field against +`backend/agent_kv/execution_serializers.py::SubmitSerializer` — every row below matches +the shipped serializer exactly; no drift found in the submit contract itself (see the +notes after the table for two spec-vs-code nuances worth flagging). + +The request is **extractor-scoped**: a per-extractor schema and its knobs live inside +`extractors`, and only fields describing the request itself stay top level. + +**Job-level fields:** + +| Field | Required | Notes | +|---|---|---| +| `file` | yes | One document per job (D12). | +| `extractors` | yes | JSON array (file part or inline JSON string), one entry per extractor. Compiled synchronously; invalid ⇒ 400, nothing billed. | +| `page_start`, `page_end` | no | 1-based inclusive range. **Job-level, not per-extractor**: the range drives the shared OCR pass and the page cap, so it cannot differ between extractors reading the same document. | +| `timeout` | no | Omitted or `0` = pure async (immediate 202). `1–300`: the view polls the job row up to the deadline and returns the result inline if the job completes, else 202 with the job id. | +| `tags`, `custom_data` | no | Echoed through. | +| `webhook_url` | no | Terminal-state POST `{job_id, status}` only — no result payload. | + +**Each `extractors` entry:** + +| Field | Required | Notes | +|---|---|---| +| `name` | yes | Which extractor: `kv` or `table`; anything else ⇒ 400. More than one entry ⇒ 400 — the format is in place for multi-extractor jobs, the execution is not built yet. | +| `keys` | yes | This extractor's own schema. For `kv`, its `keys.json` (see [§2](#2-the-extraction-schema-keys-field)); for `table`, `{"target_table": ""}` — the engine's one required extraction parameter. | +| `options` | no | This extractor's own knobs (below). An unrecognised option ⇒ 400, so a knob aimed at the wrong extractor fails loudly instead of being silently dropped. | + +**`kv` options:** + +| Option | Notes | +|---|---| +| `qa` | Default **on**. | +| `challenge` | Default **on** (~doubles LLM spend; meter records what ran). | +| `extraction_mode` | `whole-doc` (default) \| `per-page`. | +| `structured_output` | Disabled by default; enabled per deployment via `AGENT_KV_STRUCTURED_OUTPUT_ENABLED`. | +| `calculations` | Post-processing instructions; opt-in codegen. Disabled by default; enabled per deployment via `AGENT_KV_CALCULATIONS_ENABLED`. | +| `document_class` | Free-text hint (as CLI). | +| `key_notes` | Free-text notes appended to the prompt (as CLI). | + +**`table` options:** + +| Option | Notes | +|---|---| +| `instructions` | Free-text extraction guidance handed to the engine. | +| `json_structure` | Free-text description of the row structure to return. | +| `enable_header_mapping` | Default **off**. **Changes the result shape** — see [§5](#5-result--get-agent-kvjob_idresult). | +| `correct_number_separators` | Default **off**. Re-reads thousands/decimal separators in numeric cells. | +| `number_format` | `US` (default) \| `EU`. Which convention `correct_number_separators` assumes. | + +Not exposed for `table` (they are meaningless on a blind API): `output_path`, +`enable_highlight`, and the IDE callback hints `prompt_key`/`doc_name`. + +> **No backward compatibility with the pre-`extractors` flat shape.** `keys`, `qa`, +> `challenge`, `extraction_mode`, `structured_output`, `calculations`, +> `document_class` and `key_notes` are no longer accepted at top level — a request +> using the old form gets a 400 for the missing `extractors` field. The API had no +> consumers when this changed, so no alias was kept. + +Not exposed (D6): model choice, challenger model, `parallel_pages`, thinking budgets. + +**Two implementation-level notes not visible in the table:** + +- File type is allowlisted (`.pdf .xlsx .xls`) and size-capped + (`AGENT_KV_MAX_FILE_SIZE_MB`); PDFs are page-counted **locally, pre-OCR**, via + `pdfplumber` and rejected over `AGENT_KV_MAX_PAGES` before any paid work runs. Excel + has no pre-OCR page concept — it is capped by file size only at submit time (the + engine enforces a post-OCR virtual-page cap). + - Images (`.png .jpg .jpeg .tiff`) are **not** accepted, and no longer count as + 1 page. The engine treats only `.pdf/.xlsx/.xls` as a document and + `ImageLoader.load_pages` has no call site, so an accepted image returned + `success: true` with every key not-found and a page billed. They are refused at + submit until the engine side is wired. +- `timeout`'s upper bound is not the literal `300` in the spec prose — it's + `AGENT_KV_MAX_TIMEOUT_SECONDS` (default `300`, but deployment-configurable). + +### Response + +`202` (async, the default): + +```json +{ + "job_id": "5b6e9b0a-...", + "status": "dispatched", + "status_url": "/agent-kv/5b6e9b0a-...", + "created_at": "2026-08-28T10:15:00.123456+00:00" +} +``` + +`200` (when `timeout` was set and the job finished before the deadline): the full +[result payload](#5-result--get-agent-kvjob_idresult), same shape as the result +endpoint. + +`501`: [engine unavailable](#11-when-the-engine-is-unavailable-501-behavior). +`429`: rate-limited (per-key request rate, or per-org concurrent-job limit — see +[§13](#13-environment-reference)). +`402`: the organization's subscription does not permit the request — its trial has +expired, or the subscription is inactive. Nothing is dispatched and nothing is +billed. This is the same gate, the same policy and the same response bodies an +API deployment execute gets from the cloud subscription middleware; Agent-KV +applies it in the submit view instead, because its URL carries no org segment for +that middleware to resolve an organization from. Cloud-only: an OSS-only +deployment returns `501` before ever reaching it. +`400`: serializer/schema validation failure — nothing billed. + +### Example + +```bash +curl -X POST https://api.unstract.example/agent-kv/ \ + -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" \ + -F "file=@invoice.pdf" \ + -F 'extractors=[{ + "name": "kv", + "keys": { + "quotation_number": {"description": "The quote number", "required": true}, + "customer": {"name": {"description": "Bill-to name"}}, + "line_items": { + "description": "One row per line", + "_key": "sku", + "_array": { + "sku": {"description": "SKU"}, + "total": {"description": "Line total", "format": "currency"} + } + }, + "_constraints": ["count(\"line_items\") >= 1"] + }, + "options": {"challenge": false} + }]' \ + -F "webhook_url=https://example.com/hooks/agent-kv" +``` + +## 4. Status — `GET /agent-kv/{job_id}` + +Verbose, agent-centric, stage-level (`backend/agent_kv/execution_views.py::_status_document`). +**Stage names are per-extractor.** They are wire format, and `qa`/`challenge`/`codegen` +mean nothing to the table extractor, so a job's recorded stages are filtered through the +list for the extractor that actually ran +(`backend/agent_kv/constants.py::STAGE_NAMES_BY_EXTRACTOR`). + +- `kv` (superset; only stages that actually ran for this job appear, in a fixed + order): `document_processing, extraction, qa, challenge, normalize, constraints, + codegen, code_execution`. +- `table`: `table_extraction` — a single stage. The table engine exposes no node-level + progress hooks, so finer stage names would describe progress the executor cannot + actually report. It re-reports `table_extraction` as `running` while the engine works, + carrying a `steps` counter that increases as the engine passes its own progress points. + +```json +{ + "job_id": "5b6e9b0a-...", + "status": "running", + "extractors": { + "kv": { + "stage": "challenge", + "stages": [ + {"name": "document_processing", "status": "done", "seconds": 6.2}, + {"name": "extraction", "status": "done", "seconds": 11.4}, + {"name": "qa", "status": "done", "seconds": 4.1}, + {"name": "challenge", "status": "running"} + ] + } + }, + "created_at": "2026-08-28T10:15:00.123456+00:00", + "started_at": "2026-08-28T10:15:01.500000+00:00", + "completed_at": null, + "pages_total": 3 +} +``` + +Each stage entry always carries `status` (`running`|`done`) and, if reported, +`seconds`; any additional flat scalar counters the executor reported for that stage +(e.g. `keys_checked`, `flagged`, `fields_repulled`) ride alongside — nested/list +counters, and any counter using the reserved keys `status`/`seconds`, are dropped by the +internal stage-report endpoint rather than persisted (`backend/agent_kv/internal_views.py::_sanitize_counters`). + +`status` is lowercased (`pending`, `dispatched`, `running`, `completed`, `failed`, +`cancelled`); `error` is included (only) when `status == "failed"`. `pages_total` and +`completed_at` are always present (as `null` until known) — an addition beyond the +spec's illustrative example, not a drift, since spec §7.2 was explicitly a partial +sample ("Verbose, agent-centric, stage-level"). + +```bash +curl https://api.unstract.example/agent-kv/5b6e9b0a-.../ \ + -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" +``` + +## 5. Result — `GET /agent-kv/{job_id}/result` + +Re-readable until `expires_at` (D11), plus explicit `DELETE`. + +- **Non-terminal job** (`PENDING`/`DISPATCHED`/`RUNNING`): `409` with + `{"status": ""}`. +- **Expired, or `COMPLETED` with an already-swept/blank `result_ref`**: `404`. +- **`COMPLETED`**: `200` with the engine's full result object, unchanged (record, + normalized_record, per-field audit trail, `qa_passed`, `challenge_passed`, + `consistency_violations`, `cost_summary`, `timing` — spec §4; this repo does not + shape or re-validate that payload, it stores and replays exactly what the executor's + finalize call sent). +- **`FAILED`**: `200` with + `{"success": false, "status": "failed", "error": ""}`. +- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}`. + +**Calculation result shape** (when `calculations` is supplied and +`AGENT_KV_CALCULATIONS_ENABLED=true`): a `COMPLETED` result adds these fields to the +engine's standard result object: + +- `calculations_applied: true` — indicates calculation codegen ran. +- `execution: {success: bool, rows_written: int, error: string|null}` — sandbox worker + execution status. `success` is `true` if the generated code ran without error; + `rows_written` is the count of rows the code emitted; `error` is `null` on success, or + a user-safe message (e.g., `"Runtime error: division by zero"`) on failure. +- `codegen_validation_passed: bool` — whether the AST gate (layer 1 of a 5-layer + defense-in-depth model) accepted the generated code syntax. +- `calculation_rows: [...]` — an array of computed JSONL rows (size-capped). If the code + produced more rows than the cap allows, this array is empty and `calculation_rows_truncated: true` + is present instead. + +If the sandbox worker fails to execute, the job completes with `success: false` and a +user-safe `error` (e.g., `"Calculation execution timed out"`); the `execution` field +carries the inner error details. + +**Drift from the spec:** §7.3 sketches the failed-job body as +`{success: false, error, timing}`. The shipped body +(`backend/agent_kv/execution_views_result.py::result_payload`) is +`{"success": false, "status": "failed", "error": ...}` — it adds `status` and does +**not** include a `timing` key (there is nothing to time for a job that never produced +an engine result). Document the code: no `timing` on failure/cancellation bodies. + +```bash +curl https://api.unstract.example/agent-kv/5b6e9b0a-.../result \ + -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" +``` + +The payload is **keyed by extractor**, with usage attributed per extractor +alongside the authoritative total: + +```json +{ + "success": true, + "status": "completed", + "extractors": { + "kv": { + "success": true, + "record": {"quotation_number": "Q-10234", "customer": {"name": "Acme Corp"}}, + "normalized_record": {"quotation_number": "Q-10234", "customer": {"name": "Acme Corp"}}, + "keys": [ + {"key_path": "quotation_number", "qa_status": "pass", "challenge_status": "pass"} + ], + "qa_passed": true, + "challenge_passed": true, + "consistency_violations": [], + "cost_summary": { + "total_cost": 0.0, + "input_tokens": 3120, + "output_tokens": 480, + "agents": { + "kv_extractor": {"input_tokens": 2400, "output_tokens": 360, "cost": 0.0}, + "kv_qa": {"input_tokens": 720, "output_tokens": 120, "cost": 0.0} + } + }, + "timing": {"document_processing": 6.2, "extraction": 11.4} + } + }, + "usage_summary": { + "total": {"pages": 3, "input_tokens": 3120, "output_tokens": 480, "total_cost": 0.0}, + "by_extractor": { + "kv": {"pages": 3, "input_tokens": 3120, "output_tokens": 480, "total_cost": 0.0} + } + } +} +``` + +`success` and `status` are at the **top level on every terminal payload** — +completed, failed and cancelled — so a client branches on one key. They describe +the job; each extractor keeps its own `success` inside its own block. +`usage_summary.total` is the billing figure; `by_extractor` exists so a +multi-extractor job can be attributed. With one extractor they are the same numbers. +Note the **failed** and **cancelled** payloads above are NOT extractor-keyed: a +failure belongs to the job, not to one extractor. + +**`cost_summary` dollars are always `0.0` and are not a billing figure**: the executor +zeroes the engine's per-token price fields (they were stale hardcoded list prices), so +`total_cost` and every per-agent `cost` come back `0.0` — metering is what bills, from +the SDK's own litellm-priced usage records. Read `cost_summary` for its token/agent +breakdown only. `agents` is omitted entirely when no agent recorded usage (a full +cache hit). + +**The per-field audit entries in `keys[]` are keyed `key_path`, not `path`.** A live +entry carries more than the example above shows — this one is verbatim from a real run: +`{"key_path": "invoice_number", "value": "INV-…", "found": true, "qa_status": +"unchecked", "qa_attempts": 0, "line_start": 5, "line_end": 5, "normalized_value": +"INV-…"}` (`qa_status` is `"unchecked"` with `qa_attempts: 0` when the job ran with +`qa=false`). + +(The exact result shape beyond `success` is the cloud engine's contract — spec §4 — not +re-specified or validated by this repo.) + +**The `table` extractor's result** is the table engine's extraction output, filed +under `extractors.table`: + +```json +{ + "success": true, + "status": "completed", + "extractors": { + "table": { + "tables": [{"unit": "A1", "rent": 1200}], + "page_count": 3, + "table_pages": [1, 2], + "headers": ["unit", "rent"], + "row_count": 1, + "timing": {"document_processing": 6.2, "total": 41.7} + } + }, + "usage_summary": { + "total": {"pages": 3, "input_tokens": 3120, "output_tokens": 480, "total_cost": 0.0}, + "by_extractor": { + "table": {"pages": 3, "input_tokens": 3120, "output_tokens": 480, "total_cost": 0.0} + } + } +} +``` + +`tables` carries one object per extracted row; `headers` is the canonical column list +the engine settled on; `table_pages` are the 1-based pages the rows came from; and +`row_count` is the number of rows extracted. `timing` rides inside the extractor block, +and `parse_failures` appears only when some page failed to parse. There is **no +`cost_summary`** on this extractor — the table engine has no per-agent cost tracker — so +`usage_summary` is the only billing figure, as it is for `kv`. + +> **`enable_header_mapping` changes the type of `tables`.** With it **off** (the +> default), `tables` is a flat list of row objects, exactly as above. With it **on**, +> the engine wraps them and `tables` becomes +> `{"header_mapping": {...}, "rows": [...]}`. That is a breaking shape change between +> two settings of one option: a client that sets the flag must read `tables.rows` +> instead of iterating `tables`. + +## 6. Validate — `POST /agent-kv/validate` + +Compile-only check; **free of charge but not free of auth** — authenticated (valid key) +and rate-limited the same as submit (spec §6.1: "an open compile endpoint would be a +probing/DoS surface"). No job is created; nothing is billed either way. + +```bash +curl -X POST https://api.unstract.example/agent-kv/validate \ + -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" \ + -H "Content-Type: application/json" \ + -d '{"keys": {"invoice_number": {"description": "Invoice #", "required": true}}}' +``` + +Success (`200`): + +```json +{"valid": true, "leaves": 1, "arrays": 0, "constraints": 0} +``` + +Invalid schema — still `200`, not `400` (schema-compile rejection is a *response*, not +a request error): + +```json +{"valid": false, "error": "Leaf at invoice_number is missing a 'description'"} +``` + +Missing `keys` key in the body is the one case that *is* a `400`: +`{"detail": "body must include 'keys'"}`. + +## 7. Cancel — `POST /agent-kv/{job_id}/cancel` + +Defined precisely per spec §7.4: no task-revocation machinery exists anywhere in the +platform, so cancel flips the job row to `CANCELLED` via the same guarded +`mark_terminal` write gate every other terminalization path uses +(`backend/agent_kv/models.py::AgentKVJob.mark_terminal`) — later callbacks/stage +reports become no-ops; a job not yet picked up is dropped at pickup. + +A job **already running is stopped cooperatively**, not revoked. Cancellation is +detected opportunistically: the executor only learns of it on its next stage report, +which the internal endpoint answers as a no-op once the row is terminal. Both extractors +check at their engine's progress points — `kv` at every graph-node boundary, `table` at +the engine's own progress points (rate-limited, so detection can lag by a few seconds) — +and the job then terminates as `cancelled`. Work already done is still billed and +metered; work not yet started is not. A job that finishes before the cancel is noticed +simply has its result discarded on arrival. + +- Won the race (job was non-terminal): `200` `{"status": "cancelled"}`. +- Already terminal: `409` `{"status": ""}` — **note this one is the raw + uppercase enum value** (e.g. `"COMPLETED"`), unlike every lowercased `status` value + elsewhere in this API (status doc, result 409, validate). This is the code as shipped + (`execution_views.py::JobCancelView`, confirmed by + `test_job_views.py::test_cancel_on_completed_is_409_and_result_untouched`), not a + typo in this document. + +```bash +curl -X POST https://api.unstract.example/agent-kv/5b6e9b0a-.../cancel \ + -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" +``` + +## 8. Delete — `DELETE /agent-kv/{job_id}` + +Deletes the result and any residual staged input immediately, and blanks both refs +(`204`, no body). The job row itself is retained (audit trail); a later `result` fetch +404s (blank `result_ref`), and status still works. + +```bash +curl -X DELETE https://api.unstract.example/agent-kv/5b6e9b0a-.../ \ + -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" +``` + +## 9. Webhook delivery + +`webhook_url` (if given at submit) receives exactly one terminal-state POST: +`{"job_id": "...", "status": "completed"|"failed"|"cancelled"}` — a fixed payload +shape, no result content (spec §6.7, §7.1). Delivered from the `ide_callback` worker +(`workers/ide_callback/agent_kv_tasks.py::_maybe_webhook`) only on a *fresh* finalize +(a duplicate/late finalize for an already-terminal job never re-fires it). + +**SSRF controls, as actually implemented** (`workers/shared/utils/webhook_notify.py`, +function `send_webhook`) — this corrects an earlier draft of this document, which +claimed connect-time re-checking and bounded retries; the code does neither, and its +own module docstring says so: + +- **Resolution-time check only.** The host is resolved once via `socket.getaddrinfo` + and every returned address is checked against private/loopback/link-local/ + reserved/multicast/unspecified ranges *before* the request is made + (`_host_is_public`). It is **not** re-checked at connect time — the subsequent + `requests.post` call performs its own, independent DNS resolution, and nothing + pins that second resolution to the address(es) just validated. +- **Residual risk, accepted for v1: DNS-rebinding TOCTOU.** An attacker whose DNS + answer changes between the pre-check's resolution and `requests`' own resolution + a moment later (serving a public IP to the first, a private/internal IP to the + second) slips through this gap. The module's own docstring names this as a known, + accepted risk. **Fast-follow, not shipped:** IP-pinning the validated address into + the actual connection, so the two resolutions can never disagree. +- **Scheme: https-only**, in this feature's actual usage. `send_webhook` exposes a + generic `allow_http` escape hatch (for explicitly configured dev environments + elsewhere), but the Agent-KV caller (`_maybe_webhook`) never passes it — every + Agent-KV webhook is https-only in practice. +- **No redirects** (`allow_redirects=False`). +- **Fixed, minimal payload** (`{job_id, status}` only — see above) and the + **response body is never read** — only the status code is inspected. +- **Short timeout** (10 seconds). +- **No retries.** One POST attempt only; any failure (host refused by the + pre-check, timeout, connection error, non-2xx status) is logged and swallowed — + there is no retry loop anywhere in this delivery path. + +Test/dev stacks only: `AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=1` on the +ide-callback worker waives both guards (http scheme and non-public host) so the +e2e lane can deliver to a receiver on the compose host. Never set it in +production. + +## 10. Retention and TTL + +- **Input deletion is completion-triggered, not TTL-based** (spec D10: "uploaded + document deleted on job completion"). `FinalizeView.post` + (`backend/agent_kv/internal_views.py`) deletes the staged input file and blanks + `input_ref` the instant a finalize call actually wins the terminal-state guard (the + job just became `COMPLETED` or `FAILED`), via `storage.delete_input` — which only + ever touches `input_ref`, never `result_ref`/the result file. A duplicate/late + finalize call (guard already lost, since the job is already terminal) never deletes + anything: either the winning call already did, or the job reached terminal some + other way (see next bullet). +- **Cancelled jobs' inputs intentionally ride TTL instead.** `JobCancelView` marks a + job `CANCELLED` directly via `AgentKVJob.mark_terminal`, not through `FinalizeView` + — so cancellation never triggers the completion-time delete above. A late finalize + call against an already-`CANCELLED` job also no-ops (it loses the terminal guard, + same as any duplicate). A cancelled job's staged input is therefore cleaned up the + ordinary way: by the TTL sweep once `expires_at` passes, or by an explicit + `DELETE /agent-kv/{job_id}`. +- **Result retention**: `AGENT_KV_RESULT_TTL_DAYS` (default **7** days, D10's + engineering default) — results are re-readable until then, then swept by the + internal `ttl-cleanup` endpoint ([§12](#12-deploy-checklist)), which blanks + `result_ref` (and `input_ref` too, covering the cancelled-job case above, or + defensively for any input that somehow outlives completion). The job row itself is + never deleted (audit trail persists past TTL, only the object-store payloads are + dropped). +- **`expires_at` is stamped at submit time**, not at completion — + `SubmitView.post` sets `expires_at = timezone.now() + timedelta(days=AGENT_KV_RESULT_TTL_DAYS)` + before dispatch even runs. **This is a real divergence from spec D10's framing** + ("results retained 7 days" implicitly from completion): for a job that runs close to + its `timeout` ceiling, the effective post-completion retention window is slightly + under the full 7 days. Documented here as shipped behavior, not silently + reconciled — flagged for anyone tightening D10 later. + +## 11. When the engine is unavailable (501 behavior) + +The Django backend cannot see the executor plugin registry (that lives in the workers +process). Dispatching to a queue no worker consumes would hang, not error — so gating +happens **before enqueue, in the backend**, via a capability probe: + +```python +if not get_plugin("agent_kv"): + raise EngineUnavailable() # 501, "agent-kv engine not available on this deployment" +``` + +`get_plugin(...)` (`backend/plugins/__init__.py`) returns an empty dict — falsy — when +no `plugins/agent_kv/` package is installed, which is the OSS-only case by design (the +plugin is a cloud deliverable, spec §5.1/§11: "OSS scaffold merges dark"). Every +`POST /agent-kv/` on an OSS-only deployment therefore returns: + +```json +{"detail": "agent-kv engine not available on this deployment"} +``` + +with HTTP `501`. `/validate`, key management, status/result/cancel/delete on +*already-existing* jobs are unaffected by this gate — only `SubmitView` probes it, +since only submit needs the engine. + +### 11a. The cancel-at-pickup contract (internal API — frozen; build the cloud engine against this) + +Spec §7.4 states that "a job not yet picked up is dropped at pickup," but leaves the +actual mechanism unspecified. This is it, and it is load-bearing enough that it is +recorded here explicitly rather than left to be inferred from the internal-API code: + +`POST /internal/v1/agent-kv/jobs/{job_id}/stage/` — the stage-report endpoint +(`StageReportView`, `backend/agent_kv/internal_views.py`) — no-ops with +`{"ok": true, "noop": true}` whenever the target job is already terminal (its +candidate query excludes `AgentKVJob.TERMINAL`, so a terminal job's row is simply +never found). **That response body *is* the drop signal.** The FIRST stage report an +executor makes for a job it has just picked up doubles as a liveness check against a +job that was cancelled (or otherwise reached a terminal state) in the window between +dispatch and pickup — since there is no task-revocation machinery anywhere in the +platform (spec §7.4) to stop a picked-up job any other way. + +On `noop: true`, the executor **must**: + +1. Stop doing any further work on that job immediately. +2. **Still call `FinalizeView`** (`POST .../jobs/{job_id}/finalize/`) with whatever + outcome it has — success or failure, it does not matter which, since the write is + going to no-op either way. + +Step 2 is not optional. `FinalizeView`'s terminal-state guard (spec §5.4) correctly +no-ops the status write for an already-terminal job — but its concurrency-slot +release (`AgentKVConcurrencyLimiter.release`) lives in a `finally`, unconditional on +whether the guard actually won ([§10](#10-retention-and-ttl) covers the input-deletion +side of this same guard). An executor that treats `noop: true` as "nothing left to do +here" and skips the finalize call entirely leaks that job's concurrency slot for the +rest of the limiter's TTL (6 hours) instead of releasing it immediately. This is the +entire mechanism behind a pre-pickup cancel actually being observed and cleaned up +promptly — there is no other signal. + +## 12. Deploy checklist + +Everything below is required (or worth checking) to run this feature for real, +beyond `docker compose up`: + +1. **Cloud plugin**: install/enable the `agent_kv` backend capability plugin + (probed via `plugins.get_plugin("agent_kv")`) — without it, submit always 501s + ([§11](#11-when-the-engine-is-unavailable-501-behavior)). This repo ships gated + dark by design; nothing to do for an OSS-only deployment except accept the 501. + The marker the probe keys off is the mere presence of + `backend/plugins/agent_kv/__init__.py` — a cloud image ships it (via the + plugin-copy step), an OSS-only build does not. +2. **Executor worker fleet queue**: the executor dispatch derives its Celery queue name + from the executor name as `celery_executor_{executor_name}` — for Agent-KV that's + **`celery_executor_agentic_kv`** (`unstract/sdk1/src/unstract/sdk1/execution/dispatcher.py`). + `workers/run-worker.sh`'s executor role default queue list + (`celery_executor_legacy,celery_executor_agentic,celery_executor_agentic_table`) + does **not** include it — a self-hosted/OSS-only deployment running the cloud + `agentic_kv` executor plugin must add `celery_executor_agentic_kv` to that worker's + consumed queues (`CELERY_QUEUES_EXECUTOR` on the Docker path, or the equivalent + `run-worker.sh` queue map entry) or dispatched jobs will never be picked up. + **The cloud Helm chart already wires this**: both `workerExecutorV2.args` + (`--queues=...,celery_executor_agentic_kv`) and + `workerPgExecutor.env.WORKER_PG_QUEUE_CONSUMER_QUEUE` include it, and both fleets + carry `additionalConfigs: [..., agentKv]` (`charts/unstract-platform/values.yaml`, + cloud repo) so the executor pods get the `AGENT_KV_*` LLM/LLMWhisperer env alongside + the queue wiring. +3. **Callback queue**: the `ide_callback` worker must consume `agent_kv_callback` + alongside its own `ide_callback` queue, or jobs dispatch but never finalize. This is + already the hardcoded default in both `workers/run-worker-docker.sh` and + `workers/run-worker.sh` (`WORKER_QUEUES["ide_callback"] = "ide_callback,agent_kv_callback"`) + — nothing to configure for a standard deployment; see the note already in + `docker/sample.env` under "IDE Callback Worker." The cloud Helm chart matches: + `workerIdeCallbackV2.args` (`--queues=ide_callback,agent_kv_callback`) and + `workerPgIdeCallback.env.WORKER_PG_QUEUE_CONSUMER_QUEUE` + (`"ide_callback,agent_kv_callback"`) both already include it + (`charts/unstract-platform/values.yaml`, cloud repo). +4. **Internal periodic maintenance — proxy tasks now exist; nothing schedules them + yet.** Two internal endpoints do the real work + (`backend/agent_kv/internal_urls.py`, `internal_views.py::SweepView`/ + `TTLCleanupView`), and thin scheduler-side proxy tasks now call them — + `workers/scheduler/agent_kv_tasks.py::agent_kv_sweep`/`agent_kv_ttl_cleanup`, + registered under the wire names `agent_kv.sweep`/`agent_kv.ttl_cleanup` — mirroring + the dashboard-metrics periodics (`workers/scheduler/dashboard_metrics_tasks.py`) + this feature was designed to follow (spec §5.4), except the call goes through the + shared `InternalAPIClient` facade (`agent_kv_sweep`/`agent_kv_ttl_cleanup` methods on + `workers/shared/api/internal_client.py`) rather than a second bespoke HTTP client. + **Nothing calls these tasks on a schedule yet** — an operator must register both as + periodic tasks via the same PG-scheduler mechanism the dashboard-metrics periodics + use: a `PgPeriodicTask` row per task (`name`, `task_name` = the wire name above, + `queue`, `cron_string`, `enabled=True`, `pg_owned=True` once ready to go live — see + `backend/dashboard_metrics/migrations/0004_pg_periodic_tasks.py` for the exact row + shape this mirrors) before relying on TTL cleanup or stuck-job recovery: + - `agent_kv.sweep` → `POST /internal/v1/agent-kv/sweep/` — terminalizes `PENDING` + jobs never dispatched within `AGENT_KV_SWEEP_GRACE_SECONDS` (default 1 hour) + **and** `DISPATCHED`/`RUNNING` jobs stuck past `AGENT_KV_STUCK_JOB_GRACE_SECONDS` + (default 6 hours) — two independent phases per call, response + `{"swept": N, "timed_out": M}`. Suggested cadence: every few minutes. + - `agent_kv.ttl_cleanup` → `POST /internal/v1/agent-kv/ttl-cleanup/` — deletes + staged input/result files past `expires_at`. Suggested cadence: hourly or daily + (retention is measured in days). + Both endpoints are idempotent, batch-capped at 500 rows per phase per call, and + safe to call more often than needed. + + **Cloud mechanism — Kubernetes CronJobs, not the PG-scheduler.** The sweep and + TTL-cleanup logic itself lives in `backend/agent_kv/maintenance.py::run_sweep`/ + `run_ttl_cleanup` — `SweepView`/`TTLCleanupView` above are now thin wrappers + around it. Two Django management commands wrap the same functions — + `python manage.py agent_kv_sweep` and `python manage.py agent_kv_ttl_cleanup` + (`backend/agent_kv/management/commands/`) — each printing the JSON counts + (`{"swept": N, "timed_out": M}` / `{"cleaned": N}`) and exiting 0. In the cloud + deployment, a Kubernetes CronJob runs each command on the cadences above instead + of registering a `PgPeriodicTask` row. The scheduler proxy tasks + (`agent_kv.sweep`/`agent_kv.ttl_cleanup`) and their PG-scheduler registration + remain the mechanism for OSS/self-hosted deployments, which have no CronJob + equivalent to drive from. + + **Chart keys (cloud repo)**: `backend.agentKvCronJobs` in + `charts/unstract-platform/values.yaml` — `enabled: false` there (on-prem never runs + it), flipped to `enabled: true` in `charts/cloud-deployment-values/cloud.values.yaml`. + Its `jobs` list is the two commands above with concrete schedules: + `{name: sweep, command: agent_kv_sweep, schedule: "*/15 * * * *"}` and + `{name: ttl-cleanup, command: agent_kv_ttl_cleanup, schedule: "17 * * * *"}` — offset + from sweep's :00/:15/:30/:45 ticks so the two CronJobs never contend on the same + rows. Neither job needs the `agentKv` shared-config group (LLM/LLMWhisperer + creds — only the executor touches those): sweep only needs `database`/`redis`, + ttl-cleanup additionally needs `storage` for `AGENT_KV_FILE_STORAGE_CREDENTIALS` + (see item 6 below) — both already on the `backend` deployment's config list. +5. **Env vars**: every `AGENT_KV_*` setting plus `AGENT_KV_FILE_STORAGE_CREDENTIALS` — + see [§13](#13-environment-reference) and `docker/sample.env`. The executor-side vars + (read by the cloud `agentic_kv` plugin, not by the backend settings in §13) are a + separate chart group: `global.sharedConfigs.agentKv` + (`charts/unstract-platform/values.yaml`, cloud repo) — + `AGENT_KV_LLM_PROVIDER`, `AGENT_KV_LITE_MODEL`, `AGENT_KV_ADVANCED_MODEL`, + `AGENT_KV_LLM_API_KEY`, `AGENT_KV_LLMWHISPERER_API_KEY`, + `AGENT_KV_LLMWHISPERER_BASE_URL`, `AGENT_KV_MAX_TOKENS`, `AGENT_KV_PARALLEL_PAGES`, + `AGENT_KV_ENGINE_VERSION`. Of those, two are ESO (External Secrets Operator) + secrets rather than plain values — `AGENT_KV_LLM_API_KEY` and + `AGENT_KV_LLMWHISPERER_API_KEY`, mapped in `global.externalSecrets.groups.agentKv` + to the GCP Secret Manager suffixes `agent-kv-llm-api-key` / + `agent-kv-llmwhisperer-api-key`. `AGENT_KV_LLM_PROVIDER` is a v1 hard allowlist of + two values — `anthropic` and `openai` (`SUPPORTED_PROVIDERS` in the executor's LLM + adapter) — anything else raises `ConfigError` at dispatch, before any LLM call. + + **Rollout order is strict, and getting it wrong takes down the whole executor + fleet.** Both executor fleets mount this group with `envFrom: secretRef: + -agent-kv`, so no executor pod of any kind — not just Agent-KV ones — + can start until that Secret exists: enabling the group ahead of its contents + leaves `workerExecutorV2` and `workerPgExecutor` in `CreateContainerConfigError`. + Per environment, in this order: (1) create the two GCP Secret Manager secrets + `-agent-kv-llm-api-key` and + `-agent-kv-llmwhisperer-api-key`; (2) set the three + non-secret keys — `AGENT_KV_LLM_PROVIDER`, `AGENT_KV_LITE_MODEL`, + `AGENT_KV_ADVANCED_MODEL` — in that environment's own values file (under ESO they + are emitted as inline literals in the ExternalSecret's `target.template`, and a nil + value is silently **dropped**, so leaving them unset ships a Secret without them and + every Agent-KV job then fails at dispatch with `ConfigError: Missing required + Agent-KV env vars`); (3) only then roll the fleets out. `cloud.values.yaml` enables + the group fleet-wide, so step 2 is a per-environment prerequisite, not an optional + extra. In inline (non-ESO) mode the same three keys are enforced at render time by a + `fail` guard in `charts/unstract-platform/templates/shared/agent-kv-secret.yaml`, so + a mistake there is a loud deploy-time failure instead of a per-job runtime one. +6. **Object storage**: `AGENT_KV_FILE_STORAGE_CREDENTIALS` must point at a real bucket + in production (defaults to the same shared MinIO instance as workflow execution). + Paths are prefix- and org-rooted + (`{AGENT_KV_STORAGE_DIR_PREFIX}/{org_id}/{job_id}/input{ext}`, `.../result.json`) — + no document bytes ever ride the message broker. Chart key: + `global.sharedConfigs.storage.AGENT_KV_FILE_STORAGE_CREDENTIALS` + (`charts/unstract-platform/values.yaml`, cloud repo) — derived from + `fileStorageCredentials`/`MINIO_CREDS` unless set explicitly, the same fan-out + pattern as its three siblings (`WORKFLOW_EXECUTION_`, `API_`, + `HITL_FILES_FILE_STORAGE_CREDENTIALS`). +7. **`AGENT_KV_STORAGE_DIR_PREFIX` — must be set on the backend *and* the executor + fleet, to the same value** (default `unstract/agent_kv`). The **first segment is the + bucket** — s3fs/gcsfs read it that way — so it must be a bucket that already exists + (the MinIO dev bootstrap creates `unstract`); a bucket-less prefix makes every submit + fail `NoSuchBucket` and return a 500 with nothing billed. The executor reads the + backend-staged `input_ref` and keys its OCR cache under the same root + (`{prefix}/{org_id}/cache/…`), so a mismatch silently splits the cache and breaks + result read-back. Unlike the executor-only group in item 5, this one has to reach + **both** sides, so in the chart (cloud repo) it must be fanned out to the backend + *and* to `workerExecutorV2`/`workerPgExecutor` from a single source of truth — never + set independently per fleet, where the two can drift apart unnoticed. +8. **Feature flags — `calculations` and `structured_output`**: the cloud engine + cannot execute these yet, so submit rejects them with a 400 until the flags are + flipped. Both `AGENT_KV_CALCULATIONS_ENABLED` and + `AGENT_KV_STRUCTURED_OUTPUT_ENABLED` default `false`; set either to `true` only + once the executor side has shipped support for it. +9. **Executor time limit — the real baseline is 3,600s, not 7,200s.** The executor + fleet (`workerExecutorV2`/`workerPgExecutor`) is not a `FILE_PROCESSING` worker — + `WorkerConfig._get_worker_specific_timeout_defaults()` + (`workers/shared/models/worker_models.py`) falls through to the "conservative + defaults for other workers" branch for `WorkerType.EXECUTOR`: **1 hour / 3,600s** + hard limit, 3,300s soft. The chart's bare `TASK_TIME_LIMIT: "3600"` ConfigMap key + is inert — `get_celery_setting`'s 4-tier resolution (cmdline → + `{WORKER_TYPE}_{SETTING}` → `CELERY_{SETTING}` → default) means only + **`EXECUTOR_TASK_TIME_LIMIT`** (worker-specific) or `CELERY_TASK_TIME_LIMIT` + (global) actually override it; see the comment on `workerExecutorV2`'s + `terminationGracePeriodSeconds` in `charts/unstract-platform/values.yaml`, which + already documents this ("Its ceiling is task_time_limit=3600s... note the chart's + bare TASK_TIME_LIMIT is NOT read; only EXECUTOR_TASK_TIME_LIMIT / CELERY_*"). + At the 100-page cap (`AGENT_KV_MAX_PAGES`) with `extraction_mode="per-page"`, + `parallel_pages=4`, and both QA and challenge enabled (the worst-case option + combination), the arithmetic worked out in Task 9's report is: each of + `key_extractor` → `kv_qa` → `kv_challenger` fans 100 pages across 4 workers (25 + sequential rounds), each round up to ~99s if its slowest call needs a *full* retry + backoff (`SDKLLMClient`'s `_DEFAULT_MAX_RETRIES=8`) — 25 × 99s = 2,475s per stage, + 3 stages sequential = **7,425s**, a gap of **3,825s (over 2x)** against the real + 3,600s baseline, not the 225s a 7,200s baseline would suggest. That 7,425s figure + assumes every single call across all 300 (100 pages × 3 stages) hits the full + 8-retry backoff, which is not realistic — a real call is ~10-30s, so a full run at + the page cap is closer to **~2,000-2,500s** in practice; the 7,425s number is a + theoretical ceiling for capacity planning, not an expected duration. + **Recommendation: do not raise the fleet-wide time limit preemptively.** + `EXECUTOR_TASK_TIME_LIMIT` is fleet-wide — every executor task (every plugin, not + just Agent-KV) inherits it, and it's coupled to `workerPgExecutor`'s PG-visibility + lease/`vt` and `terminationGracePeriodSeconds` (raising one without the others + reintroduces the mid-flight-SIGKILL/silent-redelivery risk `UN-3964`'s comment on + `workerExecutorV2` already guards against). Measure real per-page timings from the + Task 13 integration run first, then either raise `EXECUTOR_TASK_TIME_LIMIT` (and + `terminationGracePeriodSeconds`/the PG lease together, not alone) or lower + `AGENT_KV_MAX_PAGES` for deployments that see per-page-heavy, near-cap documents in + practice. + + **What actually happens at each limit.** At the **soft** limit (3,300s) Celery raises + `SoftTimeLimitExceeded` into the task; the executor catches it, sets a run-level stop + signal that makes every in-flight LLM retry loop abort at its next backoff instead of + sleeping through the remaining ladder (up to ~30s per attempt × 8 attempts), flushes + the usage records accrued so far, and finalizes the job `failed` with the user-safe + error `"timed out"`. At the **hard** limit (3,600s) the worker is SIGKILLed: usage + records still held in memory are lost, so that run's LLM spend goes **unmetered** + (page usage, posted per-OCR-call, is unaffected). That gap is the reason the soft + limit now stops retries promptly — it is what keeps a slow run from crossing into the + hard kill. +10. **Testing lanes — Docker-free unit gate, Docker-required integration lane.** The + cloud rig's `unit-agentic-kv` group (`tests/groups.cloud.yaml`, cloud repo) runs the + executor plugin's own test suite in an isolated venv with no `requires_services` — + no Docker needed, and it's what `tox -e unit` (CI's unit-tier matrix leg) runs. The + integration tier is the one that needs Docker: it authenticates to the container + registry and boots a mock Auth0 sidecar (`.github/workflows/ci-test.yaml`, cloud + repo) for the cross-service suites. A local `unit-agentic-kv` run needs neither + Docker nor the platform stack up. + + **A real-key Agent-KV e2e run cannot go through `tests.rig run`.** Whenever a + selected group needs the platform, the rig sets `UNSTRACT_LLM_MOCK_RESPONSE` + to `MOCK_LLM_OK` (`tests/rig/cli.py`) — and an exported *empty* value counts as + unset, so there is no way to opt out from inside the rig; the lane then exercises + the mock, not a provider. To run it against real LLMWhisperer/LLM keys, boot the + stack first (`python -m tests.rig platform up`, or `docker compose` with + `tests/compose/docker-compose.test.yaml`) and then invoke pytest directly with + `UNSTRACT_BACKEND_URL`, `AGENT_KV_E2E=1` and the `AGENT_KV_*` keys exported: + `AGENT_KV_E2E=1 UNSTRACT_BACKEND_URL=http://localhost:8000 pytest tests/e2e/agent_kv`. + + Two scenarios are operator-gated on top of that: the bad-LLM-key scenario + (`AGENT_KV_E2E_BAD_KEY_JOB=1`, see the test module docstring) and the + completion-webhook scenario, which needs + `AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=1` set BOTH on the + ide-callback worker (it waives the webhook SSRF guards — https scheme + + public host — for test/dev stacks only; never set it in production) and in + the pytest process env; the receiver is reached via + `host.docker.internal` (the compose host-gateway mapping). The lane also + covers sync-wait submits (`timeout` → 200 with the inline result) and an + `.xlsx` document (`fixtures/invoice.xlsx`, the no-pre-OCR-page-count path). +11. **Sandbox worker — calculations codegen fleet** (when + `AGENT_KV_CALCULATIONS_ENABLED=true`). The optional `calculations` field + invokes out-of-process codegen and execution in a dedicated sandbox worker fleet + consuming the `sandbox_codegen` Celery queue. **Deployment order is strict:** + deploy the sandbox worker (Deployment healthy, actively consuming tasks from + `sandbox_codegen`) **before** flipping `AGENT_KV_CALCULATIONS_ENABLED=true`. + With the flag on but the sandbox down, calculation jobs fail user-safely at the + RPC timeout (they do not hang past it); however, **never leave the flag on + without a healthy sandbox fleet**, as all jobs with `calculations` will eventually + exceed their timeouts and return failures to callers. + + **Security posture:** The sandbox pod carries no LLM, OCR, or storage secrets and + runs with default-deny egress — it only executes generated calculation code against + the extracted record in-memory. This no-secrets/deny-all-egress posture is the + **Kubernetes/chart deployment guarantee**. The reference `docker-compose.yaml` + included here is a best-effort dev mirror only, not that guarantee: its + `worker-sandbox` service shares the same full worker env-file (`workers/.env`) as + every other worker for operational simplicity, so on that stack the untrusted-code + boundary is the runner's per-subprocess env scrub (spec §6.3 layer 2), not full + pod-level secret isolation — don't run untrusted `calculations` against secrets you + care about on docker-compose. User-supplied `calculations` expressions are + validated through an AST gate (layer 1 of a 5-layer defense-in-depth model) at + submit time before ever reaching the sandbox, and further constrained (parsing, + type-checking, capability allowlist) at execution time. The AST gate is a + **best-effort** layer-1 control — a fail-closed denylist that closes the cheap, + demonstrated bypasses and raises the cost of the rest, not a provably airtight + sandbox by itself. The *enforced* containment is layers 2–5: the runner's + env-scrub + rlimits (layer 2) and the pod's non-root/read-only-rootfs/no-secrets/ + deny-all-egress/per-job isolation (layers 3–5), with `runtimeClass: gvisor` as the + deferred syscall-sandbox mitigation (see the accepted v1 residual below). Do not + treat an AST-gate pass alone as proof generated code is safe. + + **Accepted v1 residual (in-pod file reads):** because generated code + legitimately needs the `open()` builtin for its input and output files, untrusted + calculation code can read a world-readable file inside its own sandbox pod and + return the contents to the submitter. The import allowlist is exactly `{json, math, + statistics, decimal, datetime, re, collections, itertools, functools, sys}` — + `pathlib`, `os`, `io`, `socket` and `urllib` are all outside it, so this is a single + read of a known path, not directory traversal and not an exfiltration path. This is contained — not by the AST gate, but + by the pod's layers 2–5: no secrets in the pod (not even the platform + `ENCRYPTION_KEY`), non-root on a read-only rootfs, default-deny egress (no + exfiltration), per-job isolation, and results returned only to the submitting + key — so the worst case is disclosure of a non-sensitive container file to the + customer who submitted the job. The deferred hardening for this is a + `runtimeClass: gvisor` sandbox. Consequently the sandbox pod's default-deny egress + and no-secrets posture are load-bearing and must not be relaxed. + +## 13. Environment reference + +All `AGENT_KV_*` settings (`backend/backend/settings/base.py`), each `os.environ.get` +with the default shown: + +| Variable | Default | Meaning | +|---|---|---| +| `AGENT_KV_PATH_PREFIX` | `agent-kv` | Top-level public URL prefix (whitelisted past tenant middleware). | +| `AGENT_KV_MAX_FILE_SIZE_MB` | `50` | Submit-time file size cap. | +| `AGENT_KV_MAX_PAGES` | `100` | Pre-OCR page cap for PDFs (§6.1). Excel has no pre-OCR page concept; images are not accepted. | +| `AGENT_KV_MAX_CALCULATIONS_BYTES` | `20000` | Byte cap on the optional `calculations` field. | +| `AGENT_KV_MAX_SCHEMA_BYTES` | `262144` | Byte cap on the raw `keys` JSON document (256 KiB). | +| `AGENT_KV_RESULT_TTL_DAYS` | `7` | Result retention window (and a cancelled job's input, which rides the same TTL — a completed/failed job's input is deleted immediately at finalize instead), stamped at submit time (see [§10](#10-retention-and-ttl)). | +| `AGENT_KV_MAX_TIMEOUT_SECONDS` | `300` | Upper bound on the submit `timeout` (synchronous-wait) field. | +| `AGENT_KV_CONCURRENT_LIMIT` | `5` | Per-organization concurrent in-flight job cap (own Redis namespace, fails open on Redis errors). | +| `AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE` | `60` | Per-key request rate limit (submit + validate), fails open on Redis errors. | +| `AGENT_KV_SWEEP_GRACE_SECONDS` | `3600` | Age (from `created_at`) before a never-dispatched `PENDING` job is eligible for the sweep. | +| `AGENT_KV_STUCK_JOB_GRACE_SECONDS` | `21600` | Age (from `dispatched_at`) before a `DISPATCHED`/`RUNNING` job is eligible for the sweep's stuck-job phase — force-failed as `"Job timed out"` (6 hours). | +| `AGENT_KV_CALCULATIONS_ENABLED` | `false` | Gates the submit `calculations` field; the engine cannot execute it yet, so submit 400s while this is off. | +| `AGENT_KV_STRUCTURED_OUTPUT_ENABLED` | `false` | Gates the submit `structured_output` field; the engine cannot execute it yet, so submit 400s while this is off. | +| `AGENT_KV_STORAGE_DIR_PREFIX` | `unstract/agent_kv` | Bucket-rooted object-store root for staged inputs/results (`{prefix}/{org_id}/{job_id}/…`), mirroring `WORKFLOW_EXECUTION_DIR_PREFIX`/`API_EXECUTION_DIR_PREFIX`. The **first segment is the bucket** and must already exist. The cloud executor reads the same variable and keys its OCR cache under `{prefix}/{org_id}/cache/…`, so both fleets must agree. Trailing `/` is stripped. | +| `AGENT_KV_FILE_STORAGE_CREDENTIALS` | *(none — must be set)* | JSON credentials for the `AGENT_KV` `FileStorageType` (`unstract/filesystem`), same shape as `WORKFLOW_EXECUTION_FILE_STORAGE_CREDENTIALS`: `{"provider": "minio", "credentials": {"endpoint_url": "...", "key": "...", "secret": "..."}}`. | + +`AGENT_KV_FILE_STORAGE_CREDENTIALS` is looked up via +`FILE_STORAGE_CREDENTIALS_TO_ENV_NAME_MAPPING[FileStorageType.AGENT_KV]` +(`unstract/filesystem/src/unstract/filesystem/file_storage_config.py`) and is required +for `stage_input`/`write_result`/`read_result`/`delete_job_files`/`delete_input` +(`backend/agent_kv/storage.py`) to have anywhere to write to. diff --git a/docs/superpowers/plans/2026-10-06-table-extractor-api-carveout.md b/docs/superpowers/plans/2026-10-06-table-extractor-api-carveout.md new file mode 100644 index 0000000000..78cff019db --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-table-extractor-api-carveout.md @@ -0,0 +1,358 @@ +# Table Extractor on the Agent-KV API — Standalone Release Carve-Out + +**Goal:** ship `POST /agent-kv/` with `extractors: [{"name": "table", "keys": {"target_table": "…"}}]` +to customers as **its own OSS PR + cloud PR off current `main`**, excluding the +`agentic_kv` extraction engine, the schema codegen path and the hardened sandbox — +all of which stay on the large PRs ([unstract#2309](https://github.com/Zipstack/unstract/pull/2309), +[unstract-cloud#1816](https://github.com/Zipstack/unstract-cloud/pull/1816)) until those stabilise. + +**Non-goal:** anything that makes the `kv` extractor reachable. This plan +deliberately leaves the KV code in the tree, dormant and tested, so the large PRs +re-enable it by reverting four one-line subtractions rather than by merging content. + +**Ticket:** [UN-4232](https://zipstack.atlassian.net/browse/UN-4232), under epic [UN-4044](https://zipstack.atlassian.net/browse/UN-4044). +**Source branches to copy from:** OSS `Feat/agent-kv-api`, cloud `UN-4044-agent-kv-cloud-executor`. +**Target branches:** OSS `feat/agent-kv-api-table` off `main`; cloud `UN-4232-agent-kv-api-table` off `main`. +**Strategy: copy-forward, not cherry-pick.** The source branches carry 100+ interleaved +commits across 338 files; reconstruct the subset by copying files, then subtracting. + +--- + +## The constraint that defines the carve-out + +`workers/plugins/agentic_table/src/api_binding.py:29-32`: + +```python +from agentic_kv.constants import AgentKVConfig +from agentic_kv.exceptions import PageCapExceeded +from agentic_kv.llm_adapter import build_llm, flush +from agentic_kv.progress import JobCancelled, StageReporter +``` + +`agentic_table/pyproject.toml` declares `unstract-agentic-table-engine` and +`unstract-extraction-seams` — **not** `agentic_kv`. The import resolves only +because both plugins are installed into the same worker image. Drop the +`agentic_kv` plugin and the table API path fails at import. + +The module's own docstring already names this ("**Known debt:** … Spec step 4 +moves all three behind the seams package"). So **Task 1 is spec §5 step 4, +pulled forward** — not throwaway work, just work done in the order the carve-out forces. + +--- + +## Task 1 — Break `agentic_table`'s dependency on `agentic_kv` (cloud) + +Move into `workers/plugins/extraction_seams/`, keeping **every env var name byte-identical** +so no chart, secret or external-secret wiring changes: + +| New seams module | Moved from | Notes | +|---|---|---| +| `src/config.py` | `agentic_kv/src/constants.py` (`AgentKVConfig`, 248 L) | Keep the `AGENT_KV_*` env names. The table path reads `advanced_model`, `lite_model`, `llmwhisperer_api_key`, `llmwhisperer_base_url`, `parallel_pages` | +| `src/llm.py` | `agentic_kv/src/llm_adapter.py` (`build_llm`, `flush`, 502 L) + `engine/llm_client_types.py` (41 L) | `llm_client_types` holds 4 small dataclasses/exceptions; it comes along | +| `src/progress.py` | `agentic_kv/src/progress.py` (`StageReporter`, `JobCancelled`, 187 L) | **Drop `ENGINE_NODE_TO_STAGE`** — it maps KV engine nodes to KV stages and means nothing to the table path | +| `src/exceptions.py` | `agentic_kv/src/exceptions.py` (`PageCapExceeded`, `ConfigError`) | Only the two the table path raises | + +Then: +- `agentic_table/src/api_binding.py` imports from `extraction_seams` — the module-scope + seam pattern is preserved, so `tests/test_executor_api_operation.py` still patches + `api_binding`'s own attributes; re-point the patch targets only. +- `agentic_table/pyproject.toml` is already correct — it never declared `agentic_kv`. +- `extraction_seams` keeps its one-way rule: nothing in it may import `agentic_table`, + `agentic_table_engine` or `agentic_kv`. + +**Cost to the large cloud PR on rebase:** 12 files inside `agentic_kv` import these +four modules; they re-point to `extraction_seams`. Mechanical, and it is step 4's +work regardless of this plan. + +--- + +## Task 2 — Stand up the API app without the KV extractor (OSS) + +**Copy from `Feat/agent-kv-api`:** + +``` +backend/agent_kv/ (the API app; 6.5k L incl. tests) +backend/backend/{base_urls,internal_base_urls,urls_v2,settings/base}.py +backend/{pyproject.toml,sample.env,uv.lock} +unstract/filesystem/ (the AGENT_KV storage type; 4 files) +unstract/agent-kv-schema/ (keep — see §Decision 3) +workers/ide_callback/ (agent_kv_callback queue; 5 files) +workers/{run-worker.sh,run-worker-docker.sh,sample.env,pyproject.toml} +docker/{docker-compose.yaml,sample.env,dockerfiles/worker-unified.Dockerfile} +tests/{compose/docker-compose.test.yaml,groups.yaml}, tox.ini, pyproject.toml, uv.lock +docs/agent-kv-api.md +``` + +**Then subtract — four lines, each independently revertible by the large PR:** + +1. **`backend/agent_kv/constants.py` — drop the `kv` entry from `EXTRACTOR_ROUTES`.** + This is the single most important line in the carve-out. `SUPPORTED_EXTRACTORS = + tuple(EXTRACTOR_ROUTES)` (`execution_serializers.py:33`), so one deletion makes a + `kv` submit return **400 `unknown extractor 'kv'; supported: ['table']`**. + + Without it, a `kv` submit is accepted with a 202 and dispatched to + `celery_executor_agentic_kv`, which has no consumer in this deployment — the job + sits forever, durably and silently. That is the exact failure mode that already + cost the team ~30 hours of firings with zero executions. + + **Leave `V1_EXTRACTOR_NAME`, `STAGE_NAMES`, `KVOptionsSerializer` and + `STAGE_NAMES_BY_EXTRACTOR["kv"]` in place.** Dormant and still under test; + `execution_views.py:45-56` already tolerates a job row naming an extractor this + build cannot describe. + +2. **`workers/run-worker.sh:76`** — remove `celery_executor_agentic_kv` from the + executor role's queue list; same in `docker/docker-compose.yaml`. + +3. **`backend/agent_kv/execution_urls.py`** — unregister the `/validate` route. + `ValidateView` compiles a `kv` keys schema via `compile_schema` + (`execution_views.py:255`) and nothing else; the table extractor's `keys` is + `{"target_table": …}`, validated by `TableKeysSerializer`. Shipping an endpoint + that validates schemas for an extractor the deployment refuses is an incoherent + public contract. Keep the view class and the `unstract/agent-kv-schema` package — + deleting them would force a content merge in files the large PR rewrites. + +4. **Drop `workers/sandbox/`** (3 files), the `worker-sandbox` compose service and + `PG_ROLE_SANDBOX` in `run-worker.sh`. See §Decision 2. + +--- + +## Task 3 — Test coverage: API and end to end (OSS) + +Explicit scope, not a trailing chore. The carve-out's whole claim is "this is the +stable subset", and the tests are what substantiate it. Two tiers, because they +fail for different reasons and run in different places. + +### 3a · API testing — contract level, no model calls + +`backend/agent_kv/tests/`, in CI on every PR, no LLM or OCR credentials. Much of +this already exists (6.5k lines, largely extractor-agnostic: auth, rate limiter, +storage, sweeps, internal views, job views) and `test_table_extractor_routing.py` +(252 L) covers the table route. Audit the submit-serializer and dispatch suites for +`kv` assumptions, then close these gaps: + +| Area | Assertions | +|---|---| +| **Routing** | `SUPPORTED_EXTRACTORS == ("table",)` · a `kv` submit returns **400, not 202** | +| **Submit validation** | missing `target_table` → 400 · a `kv` option on a table entry → 400 · unknown extractor → 400 · unknown keys **or** unknown options → 400 · more than one extractor entry → 400 · oversized `extractors` payload → 400 | +| **Auth and tenancy** | no key → 403 · revoked or wrong-org key → 403 · another org's job → **404, not 403** (no existence disclosure) | +| **Limits** | per-key rate limit → 429 · concurrency cap → 429 · a cancelled job releases its slot | +| **Lifecycle** | status shape for queued / running / completed / failed / cancelled · `extractors.table.stages` non-empty and containing only `table_extraction` · result byte-for-byte stable on re-read · DELETE then GET result → 404 · swept or TTL-expired job → 404 | +| **Dispatch** | `table` routes to `("agentic_table", "table_extract_api")` · every supported extractor has a route, a stage list **and** an options serializer — the three tables stay in step | +| **Internal endpoints** | stage report · finalize · sweep · TTL cleanup, including the cleanup-failure path | +| **Webhook** | delivered once on completion · a delivery failure does not change job state | +| **Subscription gate** | expired trial and inactive subscription both 402, byte-identical to the API-deployment path · a database error surfaces as 500, never as unmetered access | + +The routing row matters most. It is the guard on Task 2's first subtraction, and the +only thing standing between a future rebase and a `kv` submit that returns 202 and +then sits in an unconsumed queue forever. + +The unknown-keys/unknown-options row is not padding either: DRF silently discards +unrecognised keys, so `"Options"` for `"options"` would be dropped whole, `options` +would default to `{}`, and the job would run with roughly double the LLM spend the +caller asked for — with a 202 and no indication anything was ignored. + +### 3b · End-to-end testing — deployed stack, real LLM and OCR + +`tests/e2e/agent_kv/`, run first against compose, then against the dev namespace +(Task 5b). These are the tests that catch the wiring failures nobody has had a +chance to hit yet. + +| Scenario | What it proves | +|---|---| +| **Table happy path** — `rent_roll.pdf` | submit → poll → rows match the known table | +| **Excel path** — `invoice.xlsx` | the engine's own Excel branch extracts, and UN-4219's post-OCR cap applies | +| **Cancel mid-run** | the run stops, billing stops, the concurrency slot is released | +| **Page cap** | an oversized document is rejected before spend | +| **Sync-wait submit** | the result comes back inline | +| **Webhook on completion** | delivered with the right payload | +| **Unreadable PDF** | 400 at submit | +| **Metering** | page usage on the standard `Audit()` path, token and cost sums in `usage_summary` — the same trail as the IDE path | +| **Stages non-empty** | `GET /agent-kv/{job}` returns `stages: [{"name": "table_extraction", …}]` | + +That last row is load-bearing: it is the only check that catches Task 5a's cross-repo +constant drift **from the outside** — from a client's point of view, where the +symptom is a job that completes normally while reporting no progress at all. + +### 3c · Re-pointing the existing scenarios + +| Keep, re-pointed to `table_keys()` | Drop (KV-engine specific) | +|---|---| +| `test_submit_without_key_is_403` | `test_validate_good_and_bad_schema` (route unregistered) | +| `test_cancel_mid_run` | `test_happy_path_extraction` | +| `test_cancelled_job_does_not_leak_its_concurrency_slot` | `test_calculation_happy_path` | +| `test_delete_completed_job_then_result_404` | `test_hostile_calculation_fails_user_safely` | +| `test_page_cap_rejects_oversized_document` | `test_resubmit_same_document_hits_document_cache` (KV's cached `DocumentProcessor`; the table path is uncached until spec step 5) | +| `test_sync_wait_submit_returns_result_inline` | `test_bad_llm_key_ends_failed` (re-point if cheap) | +| `test_webhook_delivered_on_completion` | | +| `test_concurrency_limit_returns_429` | | +| `test_submit_unreadable_pdf_is_400` | | +| `test_excel_submit_extracts` — **keep, re-pointed.** The table engine has its own Excel branch and UN-4219's post-OCR page cap applies there | | + +Already table-native, keep as-is: `test_table_extractor_happy_path`, +`test_table_entry_without_a_target_table_is_400`, `test_a_kv_option_on_a_table_entry_is_400`, +`test_an_unknown_extractor_is_400`. + +Fixtures: keep `rent_roll.pdf` and `invoice.xlsx`; `invoice.pdf` only if a re-pointed +scenario still uses it. + +### 3d · Where each tier runs + +- **3a** — the unit and integration groups in `tests/groups.yaml`, every PR, no credentials. +- **3b** — the e2e group against compose on the PR, and against the dev namespace once + deployed (Task 5b). Gate the model-calling scenarios on the existing `require_llm` + fixture so a credential-less run skips rather than fails. +- **Cloud** — `tests/groups.cloud.yaml` carries `agentic_table`, `agentic_table_engine` + and `extraction_seams`. These go **red by design** until the OSS PR lands, exactly as + the `agentic_kv` groups do today. Say so in the PR description so a reviewer does not + read it as breakage. + +--- + +## Task 4 — The cloud half, minus the engine and the sandbox + +**Include:** + +``` +workers/plugins/agentic_table/ (9 files) +workers/plugins/agentic_table_engine/ (77 files) +workers/plugins/extraction_seams/ (5 files + Task 1's four new modules) +backend/plugins/agent_kv/ (5 files — the subscription gate) +charts/unstract-platform/templates/shared/agent-kv-secret.yaml +charts/unstract-platform/templates/shared/storage-secret.yaml +charts/unstract-platform/templates/backend/agent-kv-cronjobs.yaml +charts/unstract-platform/templates/external-secrets/external-secrets.yaml +charts/unstract-platform/templates/_helpers.tpl +charts/unstract-platform/templates/worker-v2/configmaps-specific.yaml +charts/unstract-platform/{values.yaml,unittests/agent_kv_cronjobs_test.yaml, + unittests/agent_kv_wiring_test.yaml,unittests/pg_worker_fleet_test.yaml, + unittests/render_guards_test.yaml} +charts/cloud-deployment-values/cloud.values.yaml +tests/groups.cloud.yaml, copy_cloud_deps.py (+ its test), .github/workflows/… +``` + +**Exclude:** all 97 `workers/plugins/agentic_kv/` files · `charts/unstract-platform/templates/worker-sandbox/` (5 files) · `unittests/sandbox_wiring_test.yaml` · `unittests/agent_kv_calculations_ga_test.yaml`. + +**Chart edits, not deletions:** +- `values.yaml` keeps the whole `AGENT_KV_*` block — the table path reads the same + env through Task 1's seams config — but drops the `agentic_kv` executor fleet entry + and the sandbox fleet. +- **`pg_worker_fleet_test.yaml` must be edited to assert the fleet actually shipped.** + Left untouched it either fails the build or, worse, passes while guarding a fleet + that no longer exists. The guard is the only thing standing between this deployment + and an unconsumed queue; a guard that guards nothing is worse than no guard. +- `agent_kv_wiring_test.yaml`: the `AKV-Q5` assertion on + `celery_executor_agentic_kv` comes out with the fleet entry. + +No new worker fleet is needed: `celery_executor_agentic_table` is already wired in +production for the IDE table path, which is precisely why the table work chose a +second *operation* over a new executor name (plan ruling R1). + +--- + +## Task 5 — The cross-repo constant, and a real deploy + +**5a. Pin the silent-drift constant.** `STAGE_TABLE_EXTRACTION = "table_extraction"` +(cloud `api_binding.py`) must equal `TABLE_STAGE_NAMES[0]` (OSS +`backend/agent_kv/constants.py:41`). Today **only a pair of comments ties them**. +On drift: `StageReportView` persists whatever the executor sends, `_status_document` +filters it through the OSS list, and every status response returns an empty `stages` +array while jobs complete normally. No test on either side can catch it alone. + +For a first customer-facing release this is the bug that reads as "your API is +broken". Cheapest fix: assert the literal on both sides, each test naming the other +repo's file and value, so a change to one fails the other's suite on the next sync. + +**5b. Deploy and exercise it.** Push to the dev namespace and run the table e2e +against the deployed stack. The PG consumer wiring and the agent-kv CronJobs have +never run outside compose ([UN-4213](https://zipstack.atlassian.net/browse/UN-4213)). +Dropping the sandbox removes the hardest half of that validation — no hardened pod, +no NetworkPolicy, no least-privilege DB role to prove out. + +**Exit criteria for the whole carve-out:** +- a `table` submit against the deployed stack returns rows for `rent_roll.pdf` +- `GET /agent-kv/{job}` reports a non-empty `stages: [{"name": "table_extraction", …}]` +- a `kv` submit returns 400, not 202 +- cancel mid-run stops the run and releases the concurrency slot +- the xlsx path extracts and is capped +- page usage and token/cost land on the usual `Audit()` path +- the full API suite (§3a) is green in CI without credentials +- `helm unittest` green with the reduced fleet, and `pg_worker_fleet_test` + demonstrably **fails** when a fleet entry is removed +- OSS PR merged before the cloud PR — the cloud table groups depend on the OSS routing + +--- + +## Task 6 — Documentation and the public contract + +- `docs/agent-kv-api.md:199` currently reads "Which extractor: `kv` or `table`". + It must say `table` is the only supported value on this deployment, and the + `/validate` section comes out with the route. +- Customer-facing docs in `unstract-docs` (Docusaurus). +- The `202` → poll `GET /{job}` → `GET /{job}/result` flow, the `target_table` + parameter, the `extractors.table` result shape, and the webhook. + +--- + +## Decisions + +**1 · Quota admission — deferred. Decided 2026-10-06.** +The entitlement gate ships: `backend/plugins/agent_kv/service.py` calls the same +`get_subscription` / `verify_subscription` the API-deployment middleware calls, so an +expired trial or inactive subscription gets a byte-identical 402. What is **not** +wired is quota — an org inside a valid subscription but over its page or token +allowance runs and bills anyway ([UN-4216](https://zipstack.atlassian.net/browse/UN-4216), +tagged a GA blocker). This release ships without it; revisit before open availability. + +**2 · The sandbox is excluded — decided 2026-10-06. Ship what exists.** +`agentic_table` generates Python with an LLM and executes it on **every run** +(`runner.py:1158`, `1340`) through its own `code_executor.py`, where an AST gate +blocking four builtins is the entire boundary: no rlimits, no isolated pod, no +NetworkPolicy. That pod holds LLM keys, OCR adapters and storage credentials and has +full egress. **This is exactly what the IDE table path does in production today**, so +the carve-out introduces no new class of risk — but it does widen reach from +"Prompt Studio users in the org" to "any holder of an API key". +Shipping as-is, with [UN-4215](https://zipstack.atlassian.net/browse/UN-4215) as the +immediate fast-follow rather than a backlog item. Pulling the sandbox in now +would also force the module-allowlist reconciliation (the table prompt permits 15 +modules, the gate allows 10; `copy`, `pathlib`, `string`, `textwrap`, `time`, +`unicodedata` are rejected) — which is the thing most likely to destabilise the +table path, i.e. the opposite of what this carve-out is for. + +**3 · Keep `unstract/agent-kv-schema` and `ValidateView`, unregister the route.** +The package is zero-dependency, self-contained and fully tested, and +`execution_serializers.py` imports `compile_schema` at module scope. Deleting it +forces a content merge in the file the large PR rewrites most; keeping it costs +twelve dormant files and leaves the large PR a one-line route restoration. + +**4 · Keep the URL `/agent-kv/`.** It reads oddly for a table-only API, but the first +customers' integrations are the thing a later rename breaks. Name it the agentic +extraction API in the docs and leave the path alone. + +**5 · One extractor per job stays.** `execution_serializers.py:283` rejects more than +one entry. That is exactly right for table and needs no work here. + +--- + +## Risks + +| Risk | Why it bites | Mitigation | +|---|---|---| +| Rebase cost on the large PRs | The carve-out touches `constants.py`, `execution_serializers.py`, `execution_urls.py`, `run-worker.sh`, `values.yaml` — files both large PRs own | Every subtraction is the deletion of a line whose code still exists. The large PRs re-add four lines and re-point 12 imports; no content merge | +| Temporary duplication in `extraction_seams` | Task 1 moves four modules out of `agentic_kv`, which the large cloud PR still carries copies of | Expected and bounded: the large PR's rebase re-points its 12 importers, completing spec §5 step 4 | +| A helm guard edited into uselessness | `pg_worker_fleet_test` is the only thing catching an unconsumed queue | Edit the assertions to the shipped fleet and verify the test **fails** when a fleet entry is removed | +| Uncached OCR on the table API path | Ruling R3: one LLMWhisperer pass per run until spec step 5 / [UN-4096](https://zipstack.atlassian.net/browse/UN-4096) | Cost, not correctness. Size it against expected customer volume before launch | +| Table reports one coarse stage | The engine has no node-level hooks, so `stages` is `["table_extraction"]` | Known and documented; do not invent stage names the executor cannot substantiate | + +--- + +## Sequencing + +Task 1 gates Task 4 (the cloud PR cannot build without it). Tasks 2 and 3 are OSS and +run in parallel with Task 1. Task 4 needs Task 1; Task 5 needs both PRs stood up; +Task 6 runs alongside from the start. OSS merges before cloud — the cloud table test +groups depend on the OSS routing. + +No week numbers: size this on a walkthrough. The honest shape is that Task 1 is the +only genuinely new engineering, Tasks 2–4 are subtraction and re-wiring of code that +already exists and already passes, and Task 5b is the validation nobody has done yet +for any of it. diff --git a/pyproject.toml b/pyproject.toml index d46632ef40..6019f21a17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ test-rig = [ ] [tool.uv.sources] +unstract-agent-kv-schema = { path = "./unstract/agent-kv-schema", editable = true } unstract-filesystem = { path = "./unstract/filesystem", editable = true } unstract-workflow-execution = { path = "./unstract/workflow-execution", editable = true } unstract-tool-sandbox = { path = "./unstract/tool-sandbox", editable = true } diff --git a/tests/compose/docker-compose.test.yaml b/tests/compose/docker-compose.test.yaml index 742d6b2b5e..7dcce8dd00 100644 --- a/tests/compose/docker-compose.test.yaml +++ b/tests/compose/docker-compose.test.yaml @@ -10,6 +10,18 @@ services: # Workers ask the backend for this, so this is the value that normally # takes effect for fan-out. - MAX_PARALLEL_FILE_BATCHES=${MAX_PARALLEL_FILE_BATCHES:-3} + # Lets the agent-kv e2e lane's page-cap scenario set a cap smaller than + # its 2-page fixture (default matches AGENT_KV_MAX_PAGES's own default + # in docker/sample.env; the test skips unless this is overridden <=2). + - AGENT_KV_MAX_PAGES=${AGENT_KV_MAX_PAGES:-100} + # Bucket-rooted object-store root for staged inputs/results; the first + # segment is the bucket and must exist. Must be identical to the value + # given to worker-executor-v2 below -- the executor reads back what the + # backend staged and caches OCR under the same root. + - AGENT_KV_STORAGE_DIR_PREFIX=${AGENT_KV_STORAGE_DIR_PREFIX:-unstract/agent_kv} + # Flips on the Agent-KV calc e2e lane (Task 12); unset/empty (the + # default) keeps it off so other lanes are unaffected. + - AGENT_KV_CALCULATIONS_ENABLED=${AGENT_KV_CALCULATIONS_ENABLED:-} platform-service: image: unstract/platform-service:${UNSTRACT_TEST_VERSION:-latest} @@ -28,6 +40,32 @@ services: worker-pg-executor: environment: - UNSTRACT_LLM_MOCK_RESPONSE=${UNSTRACT_LLM_MOCK_RESPONSE:-} + # Add the agent-kv executor's queue to the default set (docs/agent-kv-api.md + # §12 item 2) without dropping the queues other e2e lanes' tools dispatch + # to on this same worker -- this is a superset of docker-compose.yaml's + # own default, not a replacement for it. + # + # This MUST be WORKER_PG_QUEUE_CONSUMER_QUEUE, not CELERY_QUEUES_EXECUTOR: + # since UN-4046 made the PG transport unconditional this service runs + # `pg-queue-consumer`, which reads WORKER_PG_QUEUE_CONSUMER_QUEUE. + # CELERY_QUEUES_EXECUTOR is read only by workers/executor/worker.py -- the + # now-disabled Celery executor -- so setting it here drained nothing and + # the agent-kv lane hung in DISPATCHED with no error at the producer. + - WORKER_PG_QUEUE_CONSUMER_QUEUE=${WORKER_PG_QUEUE_CONSUMER_QUEUE:-celery_executor_legacy,celery_executor_agentic,celery_executor_table,celery_executor_smart_table,celery_executor_simple_prompt_studio,celery_executor_agentic_table,celery_executor_lookup_test} + # Agent-KV engine config, read by the cloud `agentic_table` executor plugin + # (not by backend settings) -- docs/agent-kv-api.md §12 item 5 / §13. + # `AGENT_KV_LLM_PROVIDER` is a v1 hard allowlist of `anthropic`|`openai`. + # Model names have no safe default -- Task 13b must supply real ones. + - AGENT_KV_LLM_PROVIDER=${AGENT_KV_LLM_PROVIDER:-anthropic} + - AGENT_KV_LITE_MODEL=${AGENT_KV_LITE_MODEL:-} + - AGENT_KV_ADVANCED_MODEL=${AGENT_KV_ADVANCED_MODEL:-} + - AGENT_KV_LLM_API_KEY=${AGENT_KV_LLM_API_KEY:-} + - AGENT_KV_LLMWHISPERER_API_KEY=${AGENT_KV_LLMWHISPERER_API_KEY:-} + - AGENT_KV_LLMWHISPERER_BASE_URL=${AGENT_KV_LLMWHISPERER_BASE_URL:-https://llmwhisperer-api.us-central.unstract.com/api/v2} + # Same root the backend stages into (see `backend` above) -- the executor + # reads `input_ref` from it and keys its OCR cache under + # `{prefix}/{org_id}/cache/`. Both sides MUST agree. + - AGENT_KV_STORAGE_DIR_PREFIX=${AGENT_KV_STORAGE_DIR_PREFIX:-unstract/agent_kv} worker-pg-fileproc: environment: @@ -39,6 +77,15 @@ services: environment: - MAX_PARALLEL_FILE_BATCHES=${MAX_PARALLEL_FILE_BATCHES:-3} + # Re-anchored onto worker-pg-ide-callback: UN-4046 replaced the Celery + # worker-ide-callback service with its PG-transport twin. + # Completion-webhook e2e: waive the worker's SSRF guards (https + public + # host) so a receiver on the compose host is reachable. Empty/unset (the + # default) keeps the guards mandatory; the gated webhook scenario skips. + worker-pg-ide-callback: + environment: + - AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=${AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE:-} + # The `ui` Playwright group drives a browser at the frontend's OWN origin, and # the app derives every API URL from window.location.origin. The production # nginx config serves only static files plus an SPA fallback, so `/api/v1/*` diff --git a/tests/e2e/agent_kv/__init__.py b/tests/e2e/agent_kv/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/e2e/agent_kv/conftest.py b/tests/e2e/agent_kv/conftest.py new file mode 100644 index 0000000000..9b16da9a90 --- /dev/null +++ b/tests/e2e/agent_kv/conftest.py @@ -0,0 +1,283 @@ +"""Fixtures and HTTP helpers shared by the Agent-KV e2e tests. + +This lane exercises the Agent-KV *product* API (``/agent-kv/...``, top-level, +API-key authed -- not the tenant-scoped platform surface most other e2e +lanes hit). It needs a deployment where the cloud ``agentic_kv`` executor +plugin is installed (``plugins.get_plugin("agent_kv")`` truthy) -- an +OSS-only build 501s every submit (see ``docs/agent-kv-api.md`` §11). The +module-level skip in ``test_agent_kv_e2e.py`` keeps this lane from running +by accident under a plain OSS e2e sweep; this conftest assumes that guard +already passed by the time any fixture here actually executes. +""" + +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import dataclass +from pathlib import Path + +import pytest +import requests + +from tests.e2e.conftest import _org_id +from tests.rig.runtime import PlatformEndpoints + +FIXTURES_DIR = Path(__file__).parent / "fixtures" +# A real, OCR-able 2-page invoice, generated for this lane with reportlab (the +# checked-in backend/agent_kv/tests/fixtures/two_page.pdf has no extractable +# text -- pdfplumber reads it as two blank pages -- so it can't stand in for +# an extraction fixture). It's a static binary checked into the repo; the e2e +# venv itself does not depend on reportlab to regenerate it at test time. +INVOICE_PDF = FIXTURES_DIR / "invoice.pdf" +# A small single-sheet invoice workbook (generated with openpyxl, checked in +# as a static binary like invoice.pdf): Excel exercises the no-pre-OCR-page- +# count path -- ``pages_total`` is None at submit and the engine enforces the +# post-OCR virtual-page cap instead. +INVOICE_XLSX = FIXTURES_DIR / "invoice.xlsx" + +# A real, OCR-able 2-page rent roll, generated for this lane with reportlab the +# same way invoice.pdf was, and checked in as a static binary (the e2e venv +# does not depend on reportlab to regenerate it at test time). The invoice +# fixture cannot stand in: it has no tabular region, so a table extraction +# against it would legitimately return no tables and the scenario would assert +# nothing. +RENT_ROLL_PDF = FIXTURES_DIR / "rent_roll.pdf" + +#: The table the rent-roll fixture contains, named the way the engine expects +#: (`target_table` is its one required extraction parameter). +RENT_ROLL_TABLE = "Rent roll" + + +def table_keys(target_table: str = RENT_ROLL_TABLE) -> dict: + """The `table` extractor's `keys` member. + + Every extractor entry carries `keys`; for the table extractor the thing + being asked for is a table, named by `target_table`. + """ + return {"target_table": target_table} + + +def table_result_body(resp: requests.Response) -> dict: + """The `table` extractor's own result out of an extractor-keyed payload.""" + return resp.json()["extractors"]["table"] + + +def table_stages(status_doc: dict) -> list[dict]: + """The `table` extractor's stage list out of an extractor-keyed status doc.""" + return status_doc["extractors"]["table"]["stages"] + + +# The 3 leaves the happy-path schema asks for; also used to build the schema. +INVOICE_FIELDS = ("invoice_number", "vendor_name", "total_amount") + +_DEFAULT_TIMEOUT = 30 + + +@dataclass(frozen=True) +class AgentKVAuth: + """A usable Agent-KV API key plus the backend root it's valid against.""" + + base: str # backend root, e.g. http://localhost:8000 (no trailing slash) + key: str # raw AgentKVKey UUID + org_id: str + + @property + def headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.key}"} + + @property + def exec_url(self) -> str: + return f"{self.base}/agent-kv/" + + def job_url(self, job_id: str, suffix: str = "") -> str: + return f"{self.base}/agent-kv/{job_id}{suffix}" + + +@pytest.fixture(scope="session") +def agent_kv_key( + platform: PlatformEndpoints, authed_session: requests.Session +) -> AgentKVAuth: + """Create a fresh AgentKVKey for this session via the tenant API. + + Session-scoped: one key, reused by every test in the lane (mirrors + ``api_deployment``'s ``api_deployment`` fixture). Key management sits + under the tenant-scoped platform surface + (``{base}/api/v1/unstract/{org_id}/agent-kv/keys/``), unlike the + execution endpoints this key then authenticates against. + """ + base = platform.backend_url.rstrip("/") + org_id = _org_id(authed_session, base) + prefix = f"{base}/api/v1/unstract/{org_id}" + name = f"e2e-agent-kv-{uuid.uuid4().hex[:8]}" + resp = authed_session.post( + f"{prefix}/agent-kv/keys/", + json={"name": name, "description": "agent-kv e2e lane"}, + timeout=_DEFAULT_TIMEOUT, + ) + assert resp.status_code == 201, f"create agent-kv key: {resp.text}" + body = resp.json() + return AgentKVAuth(base=base, key=body["key"], org_id=org_id) + + +#: Submit fields that describe the REQUEST rather than one extractor (§7.1). +#: The page range drives the shared OCR pass and the page cap, so it cannot be +#: per-extractor. +JOB_LEVEL_SUBMIT_FIELDS = frozenset( + {"page_start", "page_end", "timeout", "tags", "custom_data", "webhook_url"} +) + + +def kv_result_body(resp: requests.Response) -> dict: + """The `kv` extractor's own result out of an extractor-keyed payload (§7.3). + + The result endpoint returns `{"extractors": {...}, "usage_summary": {...}}`, + so a test asserting on the engine's own fields has to reach through one + level. Centralised here so the reach is spelled out once. + """ + return resp.json()["extractors"]["kv"] + + +def kv_stages(status_doc: dict) -> list[dict]: + """The `kv` extractor's stage list out of an extractor-keyed status doc. + + Stage reporting moved under `extractors.` (spec §7.2) because stage + names are extractor-specific -- `qa`/`challenge`/`codegen` mean nothing to a + Table Extractor. Centralised here so the reach is spelled out once. + """ + return status_doc["extractors"]["kv"]["stages"] + + +def submit_raw( + auth: AgentKVAuth, + file_bytes: bytes, + filename: str, + keys: dict | None, + *, + extractor: str = "table", + **fields: object, +) -> requests.Response: + """POST a submit request and return the raw response -- no assertions. + + For tests that need to inspect a non-202 outcome (403, 400, 429, ...). + ``keys=None`` omits ``extractors`` entirely (covers submits that intend to + fail before schema validation is even reached). + + Builds the extractor-scoped wire format (spec §7.0): per-extractor schema + and knobs go inside ``extractors``; only fields describing the request stay + top level. Callers keep passing knobs as plain kwargs and this routes them, + so a test still reads as "submit with this one thing changed". + """ + data: dict[str, object] = {} + options: dict[str, object] = {} + for k, v in fields.items(): + (data if str(k) in JOB_LEVEL_SUBMIT_FIELDS else options)[str(k)] = v + if keys is None: + # `extractors` is omitted entirely, so there is nowhere for per-extractor + # options to go. Silently dropping them would make a test look like it + # exercised a knob it never sent -- and it would still pass, because + # these submits are expected to fail on the missing field anyway. + assert not options, ( + f"submit_raw(keys=None) cannot carry extractor options {sorted(options)}; " + f"pass a schema, or move the field to JOB_LEVEL_SUBMIT_FIELDS if it " + f"belongs there" + ) + else: + data["extractors"] = json.dumps( + [{"name": extractor, "keys": keys, "options": options}] + ) + return requests.post( + auth.exec_url, + headers=auth.headers, + files={"file": (filename, file_bytes, "application/octet-stream")}, + data=data, + timeout=60, + ) + + +def submit( + auth: AgentKVAuth, + file_bytes: bytes, + filename: str, + keys: dict, + *, + extractor: str = "table", + **fields: object, +) -> tuple[str, str]: + """POST a submit request expected to succeed; return (job_id, status_url). + + Asserts the 202 handshake (spec §7.1 / docs §3) so every caller only ever + polls a genuinely dispatched job. A 501 here almost always means the + cloud ``agentic_table`` executor plugin isn't installed on this deployment + -- this whole lane requires it (docs §11). + """ + resp = submit_raw(auth, file_bytes, filename, keys, extractor=extractor, **fields) + assert resp.status_code == 202, ( + f"submit: HTTP {resp.status_code} (expected 202; a 501 means the " + f"agentic_table executor plugin isn't installed on this deployment): {resp.text}" + ) + body = resp.json() + job_id = body["job_id"] + status_url = body["status_url"] + assert job_id, body + return job_id, status_url + + +TERMINAL_JOB_STATUSES = {"completed", "failed", "cancelled"} + + +def poll(auth: AgentKVAuth, job_id: str, timeout_s: float = 600) -> dict: + """Poll the status document until the job reaches a terminal state. + + Returns the last status document. Fails loudly (rather than looping + forever) once ``timeout_s`` elapses. + """ + deadline = time.monotonic() + timeout_s + last: dict = {} + while time.monotonic() < deadline: + resp = requests.get( + auth.job_url(job_id), headers=auth.headers, timeout=_DEFAULT_TIMEOUT + ) + assert resp.status_code == 200, f"status poll: {resp.status_code}: {resp.text}" + last = resp.json() + if last.get("status") in TERMINAL_JOB_STATUSES: + return last + time.sleep(2) + pytest.fail(f"job {job_id} not terminal within {timeout_s}s; last status: {last}") + + +def result(auth: AgentKVAuth, job_id: str) -> requests.Response: + """GET the result endpoint and return the raw response (any status).""" + return requests.get( + auth.job_url(job_id, "/result"), headers=auth.headers, timeout=_DEFAULT_TIMEOUT + ) + + +def cancel(auth: AgentKVAuth, job_id: str) -> requests.Response: + return requests.post( + auth.job_url(job_id, "/cancel"), headers=auth.headers, timeout=_DEFAULT_TIMEOUT + ) + + +def delete(auth: AgentKVAuth, job_id: str) -> requests.Response: + return requests.delete( + auth.job_url(job_id), headers=auth.headers, timeout=_DEFAULT_TIMEOUT + ) + + +def invoice_schema() -> dict: + """A 3-leaf schema matching ``fixtures/invoice.pdf``'s obvious fields.""" + return { + "invoice_number": {"description": "The invoice number", "required": True}, + "vendor_name": {"description": "The vendor or supplier name issuing the invoice"}, + "total_amount": { + "description": "The total amount due on the invoice", + "format": "currency", + }, + } + + +def invalid_schema() -> dict: + """A schema that fails to compile: a leaf missing the required 'description'.""" + return {"total": {"format": "currency"}} diff --git a/tests/e2e/agent_kv/fixtures/invoice.pdf b/tests/e2e/agent_kv/fixtures/invoice.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a79fdf55c4f6785ce6a0b4376f0c278cfb3786f9 GIT binary patch literal 2435 zcmdT`$9n2U6yED8#>N;hWmFJCfB{pDO*7S)Vn)&k1gJ~NbryMnWS4!OH6vUu*STMA zzU-ofG;?(7f67d_Uuz_p6i0>s`2Dv(!2nyPm8Id>H2Osi>=sGbKnEnS-$%=VU{*68M zU)oEb*sBs(2uLmvgZKgLU`0a}^9z`hJqr=;jqhzu z2Skx!8ZWx(yD(!0@UU3m0xp{l6uBM&TBn-GWgA5wmKKqr z9P7eZ^%3LhEJ5qHb-z6;_YpIGf_60JPaFj%A2w^<)^|zxpmpT_nWROp&YNnYNt+?N#cAjW0hvlh|UFCccLem~)mrm`bGv-`efCsgHi)LtdHjS4yrhJ!3 z3W0e1KZ&fd^X*0(xV=R^zN2z-cO#=*J&m( zXbE@ur}N!plIe-FP_z)6HoARtJlj9l9+{AJk=f*}Fl|SQu3?Q0!QE^h7Z1s1VKh=M z_d`ppMV&lfTJKt8NAy%^&zIq1+pfezxp_`;`fBEBvCY%sV&UgolZjOgr&(>hO>@q# zMKZVFGVwOSj|1Vqrk3NsNv&fRN|PIL7kRq#qp3zbVPv}5f?MRzw+dbAZH2TAmHOOD z3PNIrINK^hQ9&~UHg|pgirov-64Q0>-RHBOR)1XTK3itvkkfMqEiQHT^;PZ8t6qdh zso5Z^im6%QP;Wk=ycFMBJ-uA;OCDR+VQ5ynl1h%R_80g^K;YMzQL{rQcu~~ch$soG5s)omW^0#nyI+)i&hpVq_tF)xlT8?vSfE} zuNRH+m_fW^>r1;kMS8bVu9lcY!$UVI?j{}^-P_yVP10PT!R14B zc;!Bg`DXRH6n|a_{n>M@PEXZbBXa?{SvRyQMH&9$vIDQalc_HbRw)`V3`KuDAj9(< z4^;3$%a8*mc!2RiV`y?rf1iBF)(Jo!u$$guvF#?TxYzMp8{ z@rB_}WjzOJ2kacL9<3Un<^@H-Sy>U-EK+GsiK{_Hx?>rzNPa6l98UO$g0+P(xtknAMX*|RH8pd1{ zn9IS#UeDXZ(?{qpPfx)>H`jZ}dn7%=r1*>75Bytl5?CPD?#d?=b|MA+Vw!o~qEEl= z;3~QY1@$wQ2*$H>H)pHv39h<{iT05xO>&$n3=R}06t9NVh|EVO)iaPoSXpp-OWdX+ zVnj%#hL&6EHYWtt9Q}}4$^oxs^tpSNO~qyEc~~pwn20IXgSiwEf!fl~`S30i4F+ip z3O^5coH^ii6SULm{gHwsQ`Es0X6b`i008B`Q?U2&2K~$dng-MD6($ASfWEx(o2l2l zYbJC<_Xe8?5nRB#v)VjEEndH)g-5`GAd0c?`10(dF?Ddf)rv_>@59XOgVd?o>O?0((=75oruy;Ktts!7}cXvv)-FS=dME|T2C6r!lV7E=y6@% zr>_u#^n0Fxh;Ltf6y_3>hCeJj?Gid&gF}i8{oLi{XpA~LbH<{Ekd6bO++~~J!jdT}gG`$f%;!(b;YaX{`*aLs-;;3l+s1G%!5fSx1 zEEBI?6J0t^LnulnD^p-P;vb5J?-V5s%=n7CF)Xg^1xhs!jwO0B;V$ZT6!sJxx^_eE z5H*%Mk2*}FXKK>QUL_mcAO9YLVfBaFv{CG+{qIxuttZVOnY;R3GqH@%C z%bAgC%8_Nh!YPSv7*f%a8O zsU}S_QJ^AW0XW-5j|m$>B$8DOK5j%*Q7!_*TDl1P7n0+?S9;{in-rUB&O{$ms4*<( z`+W96x&9!4s-e(EopYtnO=+;6(=Uj>lHh+g8Tjn&UUj}4GmvUHlLY?`_&Fyx)l2Ee z_cVYPe9)PT{Bfn-Od-xwt+c1k4IJgZ+`}7U3^27?M!#66F}dIiypwzhVx}}kW^6~s z1~s;5=Z;;LTF#W*>K;lyWd*zIw;8;fXd|1wUPjxRQz1}+x?_!$XJ%Cr849-j+rw#o zBa>;@DATPaG5;M&Or`8*0ZVD+?v!|$;+_{;_iDm6q6QzHw>}1c5Od{E`K~@er?pDX zU9Pw(Do#+EYew`Y+SsM&Rjjx|1&b&#-d-Dv!dh@3v1AsaQ?Di}1nUwtBiU1V9Q9T@ zTrdm?{nmCUUp&?#$r0uV8&9k`|4}0{GbJu~)852;xWCV^HiLIsy6ElpxsyWS z-p=Vq&gj})GXW0TRPD%yJDja&i37X0;6xh^7FxVpbSq4dE9p%!Qn*&}jOZ-4v z`LA+7g$vB`FPhR_qIc5|)bWda*=PO(M4XERreQ>QtHx@P-)m_(v@kI99%c(TIbV*8%cGmiZxR`r|(?(+~=ih z&`RasOpYzu)*<~e7$o9Yw!a`iO2lswXGXPr&uZtxdR~4_FuVWh^8lFb{<-ISb@DaY zb(=#gm72%s`IS@m3|S4G{GfM~SiY**9M72WY3W2)^}eR3;sddUGvD9c#g1|)V~WT= zC3&+?+Qv&WQmr5zuFC%etZO~SMMa%ZM~cTr=e_dnTZT(O($pz`q+7J1sKW=k0hM@| zyqLGjv?13)%?vk5%uzP_3&*iN3gnT2NyxKu&<@gtWREWYmaKx zrRgNgiRgl~=d8>p9P7_Oeig`LSXe?r1ZAaNS9|UK6Rv#O#*n?p8+m7wvk>(BB5CME zr8(4rk5W;aAe1#7-#*FWP~7GzwOw^teKlf6gfu9^^nkSZq0~aGYA%D=hr}xBWMYpy zAC%St!|dlyMIGV_@crnul^=zaq7aZGZIu$Iewcms;;v99AzU(@n- zx(MS^!G+LnX83IfY-?F_sk0RsVZA`Hj%Z1c5m#5n3LBbX#lrPKV*PH{NM!tqcwAIVjD$ROyBXm)gq>hI z;==5DL=HHGr<_SkRW+V-yBx+JoUQJK^J=mmD0( zH({< zx{s&519>8w3mDVWIIf>~kG`Gl{ITq4IRWij3EAfCV@V+Er~B)2A=k+#GtZ1CVTS*B z7v(Va?l>hTzCn0TFZ5aCE2WZzRgFk@gYS3x-h5pw42%{E!wQk2jYbu4-eWZ=WB5$f zgvMq4)VTSBCaxyez`{}bAbi@lM0+Ye)NNhUsYh@2ja21v#JSftFDUSX+m6qEK|hWF zja{9jPOGeFc$Qq#`uN?%S3!@5F01i8z%fCSN;80lZaU?T_wlX#H zV+@tq2Q~AKVUE&|g4ua<1Rgf98|ZBKHjF))xi!TjE9TUq>*$>BW?YS$8kI}N>dxXV z>=bR&<>OX$Q?(q*Of9FkiS8fKum`Sx@GLQ?xTK*QQhyFfY0Df%fSqUuqt(9wNqB{B z(K?g58Z-N(SiCvX<&4<~ac#8GEikEX*%r46)$yQTS{j@{+@AZ?#Hf<@Y_*yLO!GrU zIDd0PpK(2jy^_+^Lp)Jyq!G2-7GOExC{xS2V-;zi2T#5niL6(_F@4zd)iU<}`uU#NvQWp+Ed?_%+W)ECT7Xq$;H6c{A8mQoLIc27#^p`stJck?1 zcgH=?yJU+N>SxGLtgyOYEeS9}LfRs>4qd`mlIabD%#Q9RCs)>>O=Jdwyb26#bT+`O zK?cb+^HVUL&ZWT=6Wh+1<|S+j2UQ9s+#Yj1dwEOI+Mn=Ei3!ydE4O6A=#FV51l~IA#kwkRKdbXxl!W@;YGrQ+zGzTM2J5qNBwK zkOcFz@dmm22nqhY7o=;uV~da~)lz|MbY8--MXILjRN_clqoq{gL`I}eXXKkF7i)WIVGS^h#@igRie&DZG zgcwel_J9XQqE>thL5UMjP)V!GPu%o4b`+~dgGY^=0MogYAPJc!>w6>CVd>WXa6Lp( z3BxmaW!A@Zc6s^2wvqTJVkW(_5nB8*je+1>COKNlD(4(QavAn+{!ku!ZwW?%klmy; z)*w2!t}S*_;;m(Yha(@@fwsvCyLBSFP1$mh{7#) z+r5e(r}b{~<4I3c2eP|Yq9{l<*$Zt-NY9#2AL_m!cJbAdC*g%h!f&mW)ssR_j)u35 zw*wG!@#y734HLzYvWA*&z@G$*ZLPFdV~G8MA(`P9!N0`xZ<_xWR#Te%wI{-)F||}{ z0Q!oFV(c=(DjK@Ds1wS_yY-blJB%=Z26MZx88WN;yMv>UctVJ*{*)}$ffM%tsBfDE z0Fh;)QJq|(=1x8;UA_@UqPuygiO`v80mgd$b$^PEzQDChwwba`35Dv%Po?(Ag+r@Y zD^whKZ>?FZTK2f2Z^O*fZ>BtAnrkvH^K7bwor;&f!O!)ju=jYn3V@Kz9;HNG13=TEhH{a5 zN29hR6a!cM%JSk7xz?WQ%{K3T7~-y_r*dI@Lmb;7-;&{Ra71Pj*p(0XMx)QUqyc-= z32ShW!Lgmx&y9NH1v*7rD~Sk#z>mlmP)EI^5#LMw>+I98yKQQl#_EI{G3Cc5-0<}R z*Uiere&y7DOX<~Uhnv@p~4y3ccF9e?!?ZbHu+o z`BmW6UiceGa_yg$|EDuv1z)Z6-(XivU&So=e^mX|I9E&QcN~3;Rrg;COHUgQP0UXRFXalv4B^ON9RT{kK1Xf=-t0cHkPw$V4`Dkp?ga&@A+?H{VSmdj|dB;0XaOc-Io*IjPM)py$}9_WnHQARR4>rlwcX!kLY85 zP+$5=Uk#grL2^LO^B<61r0GzwzJM9tQy@b#fX^{t0muQgJv6hgd8LU;}*k z3NQ|{z618*0PWfPfAJu5<$>p1*V1raj2+aoOuX<&&O6kvIa7l!G_36v3jCFf>e<~{ zHi$ygGGC6?*@*$NW+Uk8mMMDJM#VfsF&QdH^K_QW(OiP!A`}&Q=es@JMlU~Gw&s8+ z=D^l{PY8~>c2*HK;cGp5;qT~c8`>`*1?E?EIFH3H(Z%JlM=CDSv+10Ws&90pVSOXr zHL%HN)Abe?iVaUET-c-p4d*%)ymb7U3it#rX|k^D-^xF ze-|L>hKpUmnOhy!n$0XHB3$EM!5SFDw+)umLFU%o0X9c@f*cnEeI#V)w7NN_M#m$= zX<-&QsO+|7t(0>)5wjo11%(;K%VcBngw$ka8si74@p`vR($T6(i*>ac)7|@ZvQXk0 zf^XG#s{H04CDMLsxrK#Bcs3f3eb68+J}_Oz$5MYS-AKI)G~c^BRls#8Ip zG)m2!S}XgHeb!%2W=@p_9op97a7%ofY6#tr3++Ff%` z{k!M=k!nPPtGZa+Q{{Gx^xImjAq>*9dYu*O{=>PpX@}>PxyT&~>YbcM4-Ye;M=tHq zy+~JEb46vtYu~KE@m3{p3X>;ykXYW)J4kM`Nw)%J($gQ zc~se2<6ld-d%wlwt&d;Y!ha2LHggl;jv62n=Sb)CcD|}>>4@|&D3equ6oUN3w@y6M z4{~T4->2^5fpRtk&m@4xPETYzTl+0(u&l2GvSMUS(W-U71RUaytG>vooUkO(8qX|irY;+ zB|j$?+<+ST;w>TU-znyNpA##qmLOGT$I|#Q9qAi^V9l6@cd5{*xSu0t%Rk7qjM(Zh z0WNbN?)YJCGfw5~MYg%?X{e}%%_mpaeBE=Ld4z6sK5drdlaeS(5;Llci2>EnIq5tN z--{>t5URDJteD?FJ6WO6=jP_d4z`D_cH|gLu9gF&SbyY6!9$w5E7kTc@=g$3aHpH!P$v8J51!u^>WMSEuc_uTH5H z&~!YCZZ1&-*)%s6@CJ}V;OT9-z#C|IJ=A|eUN|mo5!&u0Kn%s@h)}5Bmx#XrG+e;g literal 0 HcmV?d00001 diff --git a/tests/e2e/agent_kv/test_agent_kv_e2e.py b/tests/e2e/agent_kv/test_agent_kv_e2e.py new file mode 100644 index 0000000000..b04578cea6 --- /dev/null +++ b/tests/e2e/agent_kv/test_agent_kv_e2e.py @@ -0,0 +1,709 @@ +"""E2E: the Agent-KV product API against a real running platform. + +This lane needs the cloud ``agentic_kv`` executor plugin (docs/agent-kv-api.md +§11) -- an OSS-only deployment 501s every submit, so it is NOT part of a plain +OSS e2e sweep. The whole module skips unless both: + +- ``UNSTRACT_BACKEND_URL`` is set (the usual e2e platform-URL gate every + other lane in this tree relies on), AND +- ``AGENT_KV_E2E=1`` is set explicitly -- so this lane never runs by + accident just because a platform happens to be up; it must be opted into + for a run that actually ships the plugin. + +A second, narrower gate (the ``require_llm`` fixture below) applies only to +the two scenarios whose assertions depend on a job actually reaching +COMPLETED (real extraction happened): either ``AGENT_KV_LLM_API_KEY`` or +``UNSTRACT_LLM_MOCK_RESPONSE`` must be set. Every other scenario here is +gated purely by request-time checks (auth, schema compile, the concurrency +limiter, the pre-OCR page cap) that resolve before any LLM call, so they +don't need either. + +Values extracted under ``UNSTRACT_LLM_MOCK_RESPONSE`` are whatever the mock +config returns, not real answers -- assertions here deliberately check +*structure* (the requested keys are present) rather than field values, for +both the mock and a real-key run, to keep this lane's outcome independent of +model behavior. + +Test order matters: the concurrency scenario is deliberately defined LAST +(see its own docstring) and ``tests/groups.yaml``'s ``e2e-agent-kv`` entry +pins ``parallel: false`` for the same reason -- see that scenario for why. + +**A third, operator-driven gate: ``AGENT_KV_E2E_BAD_KEY_JOB=1``.** One +scenario here (``test_bad_llm_key_ends_failed``) needs a *dispatched* job to +end FAILED, which no request-time check can produce -- the only deterministic +way to get there is to run the platform with a deliberately invalid +``AGENT_KV_LLM_API_KEY`` so the executor's LLM calls all fail. That is a +whole-stack configuration, not something a test can arrange, so the scenario +skips unless an operator sets ``AGENT_KV_E2E_BAD_KEY_JOB=1`` to declare "this +stack is running with a bad Agent-KV LLM key on purpose". Note that every +other scenario in this module that needs a job to COMPLETE will (correctly) +fail on such a stack -- run this one on its own, e.g.:: + + AGENT_KV_E2E=1 AGENT_KV_E2E_BAD_KEY_JOB=1 pytest tests/e2e/agent_kv -k bad_llm_key + +What it asserts is the *shape* of the failure, not its text: the job reaches +``failed`` and the ``error`` the customer sees is user-safe -- no filesystem +paths, no ``Permission denied``, no provider response body (spec Sec 8's +``" failed: "`` contract). +""" + +from __future__ import annotations + +import concurrent.futures +import json +import os +import time + +import pytest +import requests + +from tests.e2e.agent_kv.conftest import ( + INVOICE_PDF, + INVOICE_XLSX, + RENT_ROLL_PDF, + AgentKVAuth, + cancel, + delete, + poll, + result, + submit, + submit_raw, + table_keys, + table_result_body, + table_stages, +) + +if not (os.environ.get("UNSTRACT_BACKEND_URL") and os.environ.get("AGENT_KV_E2E") == "1"): + pytest.skip( + "agent-kv e2e lane needs UNSTRACT_BACKEND_URL and AGENT_KV_E2E=1 " + "(this lane requires the cloud agentic_kv executor plugin -- see " + "docs/agent-kv-api.md §11)", + allow_module_level=True, + ) + +pytestmark = [pytest.mark.e2e] + +_INVOICE_BYTES = INVOICE_PDF.read_bytes() +_RENT_ROLL_BYTES = RENT_ROLL_PDF.read_bytes() + +# Bounded wait for the org's concurrency slots to free up again. Used by the +# cancel scenario (a cancelled job must not leak its slot) and by the +# concurrency scenario's own cleanup. +_SLOT_DRAIN_TIMEOUT_S = 120 +_SLOT_DRAIN_POLL_INTERVAL_S = 3 + + +def _validation_error_attrs(body: object) -> set[str]: + """The field names a 400 body blames, across BOTH error envelopes. + + This lane runs against whichever deployment is up, and the two differ: + + - OSS DRF default -- ``{"file": ["Unreadable PDF"]}``: the field names are + the top-level keys. + - Cloud (``drf-standardized-errors``) -- + ``{"type": "validation_error", "errors": [{"code": "invalid", + "detail": "Unreadable PDF", "attr": "file"}]}``: the field names are the + ``attr`` of each entry under ``errors``. + + Returns an empty set for anything that is neither shape, so a caller's + ``assert "file" in _validation_error_attrs(body), body`` fails loudly with + the real body rather than raising a ``KeyError``/``TypeError``. + """ + if not isinstance(body, dict): + return set() + errors = body.get("errors") + if isinstance(errors, list): + return { + entry["attr"] + for entry in errors + if isinstance(entry, dict) and isinstance(entry.get("attr"), str) + } + return {key for key in body if isinstance(key, str)} + + +def _cheap_submit(auth: AgentKVAuth) -> requests.Response: + """One raw submit with QA and challenge off -- the cheapest job this API + will accept, used purely to occupy/probe a concurrency slot. + """ + return submit_raw( + auth, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + ) + + +def _drain_until_slot_free( + auth: AgentKVAuth, timeout_s: float = _SLOT_DRAIN_TIMEOUT_S +) -> bool: + """Probe with cheap submits until one is ACCEPTED (proof a slot is free) + or the bounded wait runs out. Any accepted probe is cancelled again so it + doesn't hold the slot it just proved was available. + + Returns True if a probe was accepted, False if the window expired or the + probes stopped being answerable. Never raises -- callers decide whether a + False is an assertion failure (the cancel scenario) or merely a + best-effort cleanup giving up (the concurrency scenario). + """ + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + probe = _cheap_submit(auth) + except Exception: # noqa: BLE001 - probing only + return False + if probe.status_code == 202: + try: + cancel(auth, probe.json()["job_id"]) + except Exception: # noqa: BLE001 - cleanup only + pass + return True + time.sleep(_SLOT_DRAIN_POLL_INTERVAL_S) + return False + + +@pytest.fixture +def require_llm() -> None: + """Skip a scenario that needs a job to actually reach COMPLETED. + + Every other scenario in this module resolves before any LLM call + (auth/schema/limiter/page-cap checks); only the two that poll a job all + the way to a genuine COMPLETED result need this. + """ + has_key = os.environ.get("AGENT_KV_LLM_API_KEY") + has_mock = os.environ.get("UNSTRACT_LLM_MOCK_RESPONSE") + if not (has_key or has_mock): + pytest.skip( + "needs AGENT_KV_LLM_API_KEY or UNSTRACT_LLM_MOCK_RESPONSE so the " + "job can actually reach COMPLETED" + ) + + +# --------------------------------------------------------------------------- +# 1. Auth +# --------------------------------------------------------------------------- + + +def test_submit_without_key_is_403(agent_kv_key: AgentKVAuth) -> None: + resp = requests.post( + agent_kv_key.exec_url, + files={"file": ("invoice.pdf", _INVOICE_BYTES, "application/pdf")}, + data={"keys": '{"total": {"description": "Grand total"}}'}, + timeout=30, + ) + assert resp.status_code == 403, resp.text + + +# --------------------------------------------------------------------------- +# 2. Validate +# --------------------------------------------------------------------------- + + +def test_table_extractor_happy_path(agent_kv_key: AgentKVAuth, require_llm: None) -> None: + """A `table` entry runs the table engine and files its result under `table`. + + Before the job row recorded its extractor, every response keyed by the + hardcoded `kv` name -- a table job's output would have been filed under the + wrong extractor, which no unit test on either side would have caught. + """ + job_id, status_url = submit( + agent_kv_key, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + extractor="table", + ) + assert status_url.endswith(job_id), status_url + + status_doc = poll(agent_kv_key, job_id, timeout_s=600) + assert status_doc["status"] == "completed", status_doc + # Keyed by the extractor that ran, and by that one only. + assert set(status_doc["extractors"]) == {"table"}, status_doc + + # R7: the stage is recorded AND visible. StageReportView persists whatever + # name the executor sends, so a KV-only stage-name filter would leave this + # list empty while the job still completed. + stages = {s["name"]: s for s in table_stages(status_doc)} + assert "table_extraction" in stages, table_stages(status_doc) + assert stages["table_extraction"]["status"] == "done", stages + + resp = result(agent_kv_key, job_id) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True, payload + assert payload["status"] == "completed", payload + + body = table_result_body(resp) + # Structure, not values: this lane runs under a mock LLM as well as a real + # key, so field values are not assertable (see the module docstring). + assert isinstance(body["tables"], list), body + assert "page_count" in body, body + assert "row_count" in body, body + + assert set(payload["usage_summary"]["by_extractor"]) == {"table"}, payload + + # Re-readable until TTL (D11), same contract as the kv path. + again = result(agent_kv_key, job_id) + assert again.status_code == 200, again.text + assert again.json() == payload, "result must be stable on re-read" + + +def test_table_entry_without_a_target_table_is_400(agent_kv_key: AgentKVAuth) -> None: + """Rejected at submit, before any paid work -- `target_table` is the + engine's one required extraction parameter. + """ + resp = submit_raw( + agent_kv_key, _RENT_ROLL_BYTES, "rent_roll.pdf", {}, extractor="table" + ) + assert resp.status_code == 400, resp.text + assert "target_table" in resp.text, resp.text + + +def test_a_kv_option_on_a_table_entry_is_400(agent_kv_key: AgentKVAuth) -> None: + """Per-extractor options exist so an option aimed at the wrong extractor + cannot be silently dropped and change what the caller is billed for. + """ + resp = submit_raw( + agent_kv_key, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + extractor="table", + qa=True, + ) + assert resp.status_code == 400, resp.text + assert "qa" in resp.text, resp.text + + +def test_an_unknown_extractor_is_400(agent_kv_key: AgentKVAuth) -> None: + resp = submit_raw( + agent_kv_key, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + extractor="nonexistent", + ) + assert resp.status_code == 400, resp.text + + +# --------------------------------------------------------------------------- +# 4. Failure body (submit-time rejection) +# --------------------------------------------------------------------------- + + +def test_submit_unreadable_pdf_is_400(agent_kv_key: AgentKVAuth) -> None: + """A ``.pdf``-named file that isn't a real PDF is rejected before dispatch. + + This is the submit-serializer's own page-count check + (``pdfplumber.open()`` raising -> ``{"file": "Unreadable PDF"}``), which + is deterministic and needs no LLM/engine work to reach. The executor's + own FAILED-terminal path (a job that *dispatches* successfully but the + engine later fails) has no deterministic trigger reachable without a + real key/schema combination that's guaranteed to fail -- that path is + covered by the unit suites instead (e.g. + ``backend/agent_kv/tests/test_job_views.py``, + ``backend/agent_kv/tests/test_dispatch.py``). + """ + garbage = b"this is not a pdf file, just garbage bytes" * 20 + resp = submit_raw(agent_kv_key, garbage, "garbage.pdf", table_keys()) + assert resp.status_code == 400, ( + f"expected 400 (a 501 here means the agent-kv engine plugin isn't " + f"installed on this deployment): {resp.text}" + ) + body = resp.json() + assert "file" in _validation_error_attrs(body), body + + +# --------------------------------------------------------------------------- +# 5. Cancel +# --------------------------------------------------------------------------- + + +def test_cancel_mid_run(agent_kv_key: AgentKVAuth) -> None: + job_id, _ = submit( + agent_kv_key, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + ) + cancel_resp = cancel(agent_kv_key, job_id) + # Either cancel won the race (200) or the job was already terminal by the + # time it landed (409) -- both are correct per docs §7; either way the + # status endpoint must agree with what cancel just reported. + assert cancel_resp.status_code in (200, 409), cancel_resp.text + cancel_body = cancel_resp.json() + + status_resp = requests.get( + agent_kv_key.job_url(job_id), headers=agent_kv_key.headers, timeout=30 + ) + assert status_resp.status_code == 200, status_resp.text + final_status = status_resp.json()["status"] # lowercased, per docs §4 + + if cancel_resp.status_code == 200: + assert cancel_body == {"status": "cancelled"}, cancel_body + assert final_status == "cancelled", status_resp.json() + else: + # docs §7: the 409 body's status is the RAW uppercase enum value, + # unlike every other status field in this API. + assert cancel_body["status"] == final_status.upper(), (cancel_body, final_status) + + +def test_cancelled_job_does_not_leak_its_concurrency_slot( + agent_kv_key: AgentKVAuth, +) -> None: + """A cancelled job must give its concurrency slot back. + + ``JobCancelView`` marks the job ``CANCELLED`` directly and never touches + ``AgentKVConcurrencyLimiter``; the slot is released by the job's own + *finalize* call, in a ``finally`` (docs/agent-kv-api.md §11a). So the + release is asynchronous -- it lands whenever the executor that picked the + job up notices the cancellation and calls back -- which is exactly the + kind of path where a leak hides: nothing in the cancel request itself + would fail if the release were dropped, and the only other backstop is + the limiter's 6-hour slot TTL. + + This is a bounded probe, not a saturation test: it proves the limiter is + still handing out slots after a cancel (i.e. a cancel cannot wedge it), + and it reuses the same drain helper the concurrency scenario ends with. + It deliberately does NOT saturate the org first -- doing so here would + make every scenario defined after it flaky, which is why the one + saturating scenario in this module is defined last. The saturated form of + this check is that last scenario's own drain. + """ + job_id, _ = submit( + agent_kv_key, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + ) + cancel_resp = cancel(agent_kv_key, job_id) + assert cancel_resp.status_code in (200, 409), cancel_resp.text + + assert _drain_until_slot_free(agent_kv_key), ( + f"no submit was accepted within {_SLOT_DRAIN_TIMEOUT_S}s of cancelling " + f"job {job_id}; the limiter is refusing new work, which means a slot " + f"was leaked rather than released by finalize" + ) + + +# --------------------------------------------------------------------------- +# 6. Delete +# --------------------------------------------------------------------------- + + +def test_delete_completed_job_then_result_404( + agent_kv_key: AgentKVAuth, require_llm: None +) -> None: + job_id, _ = submit( + agent_kv_key, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + ) + status_doc = poll(agent_kv_key, job_id, timeout_s=600) + assert status_doc["status"] == "completed", status_doc + + del_resp = delete(agent_kv_key, job_id) + assert del_resp.status_code == 204, del_resp.text + + after = result(agent_kv_key, job_id) + assert after.status_code == 404, after.text + + +# --------------------------------------------------------------------------- +# 7. Page cap +# --------------------------------------------------------------------------- + + +def test_page_cap_rejects_oversized_document(agent_kv_key: AgentKVAuth) -> None: + fixture_pages = 2 # fixtures/invoice.pdf + cap_raw = os.environ.get("AGENT_KV_MAX_PAGES") + if cap_raw is None or int(cap_raw) > fixture_pages: + pytest.skip( + f"AGENT_KV_MAX_PAGES={cap_raw!r} exceeds the fixture's " + f"{fixture_pages} pages; set AGENT_KV_MAX_PAGES<={fixture_pages} " + "on the backend service to exercise the cap" + ) + resp = submit_raw(agent_kv_key, _RENT_ROLL_BYTES, "rent_roll.pdf", table_keys()) + assert resp.status_code == 400, resp.text + body = resp.json() + assert "file" in _validation_error_attrs(body), body + + +# --------------------------------------------------------------------------- +# 8. Executor-side failure (operator-configured bad LLM key) +# --------------------------------------------------------------------------- + + +def _assert_user_safe_error(error: str) -> None: + """No internals in a customer-visible error string (spec §8).""" + lowered = error.lower() + for leak in ("permission denied", "traceback", "errno", "sk-", "api_key"): + assert leak not in lowered, f"internal detail {leak!r} leaked into: {error!r}" + # No filesystem paths: the engine's own text used to carry the executor's + # tempfile/page-image directories verbatim. + for leak in ("/app/", "/tmp/", "/var/", "storage/processing", "\\"): + assert leak not in error, f"a filesystem path leaked into: {error!r}" + + +def test_bad_llm_key_ends_failed(agent_kv_key: AgentKVAuth) -> None: + """A job that DISPATCHES and then fails in the executor ends ``failed`` + with a user-safe error. + + Every other failure scenario in this module is a request-time rejection + (400/403/429) that never reaches the engine. This one covers the other + half of the contract -- the terminal FAILED path -- and it needs the whole + stack to be running with a deliberately invalid ``AGENT_KV_LLM_API_KEY``, + which no test can arrange for itself. Hence the explicit operator gate + (see the module docstring for how to run it). + + The assertion is about SHAPE, not text: whatever went wrong inside the + engine, the customer-visible ``error`` must not carry a filesystem path, a + ``Permission denied``, or a provider response body -- spec §8 mandates the + user-safe ``" failed: "`` form (or one of the fixed strings + ``cancelled`` / ``timed out``), which is what the executor now builds from + the node listener's exception rather than echoing the engine's own text. + """ + if os.environ.get("AGENT_KV_E2E_BAD_KEY_JOB") != "1": + pytest.skip( + "needs a platform deliberately configured with an INVALID " + "AGENT_KV_LLM_API_KEY; set AGENT_KV_E2E_BAD_KEY_JOB=1 to declare " + "that this stack is running that way (see the module docstring)" + ) + + job_id, _ = submit( + agent_kv_key, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + ) + + status_doc = poll(agent_kv_key, job_id, timeout_s=600) + assert status_doc["status"] == "failed", status_doc + assert "error" in status_doc, status_doc + error = status_doc["error"] + assert isinstance(error, str), status_doc + assert error, status_doc + + _assert_user_safe_error(error) + + # The result endpoint reports the same failure, in the docs §5 shape. + resp = result(agent_kv_key, job_id) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["success"] is False, body + assert body["status"] == "failed", body + _assert_user_safe_error(body["error"]) + + +# --------------------------------------------------------------------------- +# 9. Document cache (spec §6.5 document tier) +# --------------------------------------------------------------------------- + + +def test_sync_wait_submit_returns_result_inline( + agent_kv_key: AgentKVAuth, require_llm: None +) -> None: + """A submit with ``timeout`` seconds sync-waits: if the job reaches a + terminal state inside the window, the response is 200 with the result + payload itself (docs §3) -- no polling leg. Uses the same invoice as the + happy path, so a warm document cache keeps the wait well inside the + window; even a cold cache (~15 s pipeline) fits comfortably. + """ + resp = submit_raw( + agent_kv_key, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + timeout=120, + ) + assert resp.status_code == 200, ( + f"sync-wait submit: HTTP {resp.status_code} (expected 200 with the " + f"inline result): {resp.text}" + ) + body = resp.json() + # `success`/`status` are top level on every terminal payload; the engine's + # own record is namespaced per extractor (spec §7.3). + assert body.get("success") is True, body + body = table_result_body(resp) + assert isinstance(body["tables"], list), body + assert "row_count" in body, body + + +# --------------------------------------------------------------------------- +# 9c. Excel input +# --------------------------------------------------------------------------- + + +def test_excel_submit_extracts(agent_kv_key: AgentKVAuth, require_llm: None) -> None: + """An .xlsx submit runs the whole pipeline. Excel has no pre-OCR page + count (``pages_total`` stays None at submit) and takes the post-OCR + virtual-page cap path instead. Structure-only assertions, like the + happy path. + """ + job_id, _ = submit( + agent_kv_key, INVOICE_XLSX.read_bytes(), "invoice.xlsx", table_keys("Line items") + ) + doc = poll(agent_kv_key, job_id) + assert doc["status"] == "completed", doc + body = table_result_body(result(agent_kv_key, job_id)) + assert body.get("success") is True, body + assert isinstance(body["tables"], list), body + assert "page_count" in body, body + + +# --------------------------------------------------------------------------- +# 9d. Webhook delivery (operator-gated) +# --------------------------------------------------------------------------- + + +def test_webhook_delivered_on_completion( + agent_kv_key: AgentKVAuth, require_llm: None +) -> None: + """Completion-webhook delivery, end to end: submit with a ``webhook_url`` + pointing at a receiver on the compose host and wait for the POST. + + Operator-gated: the worker's SSRF guards (https + public host, spec + §6.7) refuse a host-local receiver unless the stack runs with + ``AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=1`` on the ide-callback + worker (test/dev only), reached via ``host.docker.internal`` (compose + host-gateway mapping). Skips unless the operator set the same env for + the test run to declare that setup. + """ + if os.environ.get("AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE", "").lower() not in ( + "1", + "true", + "yes", + ): + pytest.skip( + "webhook e2e needs AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=1 " + "on both the ide-callback worker and this test run" + ) + + import http.server + import threading + + hits: list[dict] = [] + + class _Receiver(http.server.BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 - stdlib API name + length = int(self.headers.get("Content-Length", "0")) + hits.append(json.loads(self.rfile.read(length) or b"{}")) + self.send_response(200) + self.end_headers() + + def log_message(self, *args): # keep pytest output pristine + pass + + server = http.server.HTTPServer(("0.0.0.0", 0), _Receiver) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + job_id, _ = submit( + agent_kv_key, + _RENT_ROLL_BYTES, + "rent_roll.pdf", + table_keys(), + webhook_url=f"http://host.docker.internal:{port}/agent-kv-hook", + ) + doc = poll(agent_kv_key, job_id) + assert doc["status"] == "completed", doc + deadline = time.time() + 30 + while time.time() < deadline and not hits: + time.sleep(0.5) + assert hits, "no webhook POST arrived within 30s of completion" + assert hits[0] == {"job_id": job_id, "status": "completed"}, hits + finally: + server.shutdown() + thread.join(timeout=5) + + +# --------------------------------------------------------------------------- +# 9e. Calculations (sandbox worker, operator-gated) +# --------------------------------------------------------------------------- + +_CALC_ENV_GATE = "AGENT_KV_E2E_CALCULATIONS" + + +def _skip_unless_calculations_declared() -> None: + """Calculations need a stack running with ``AGENT_KV_CALCULATIONS_ENABLED + =true`` on the backend AND the sandbox codegen worker fleet actually up + and consuming ``sandbox_codegen`` (docs/agent-kv-api.md §11) -- neither + of which this test process can see or arrange for itself. So, like the + bad-LLM-key scenario above, it needs an explicit operator declaration + that the stack under test is configured that way; not every stack that + runs the rest of this lane has the sandbox worker running. + """ + if os.environ.get(_CALC_ENV_GATE) != "1": + pytest.skip( + "calculation e2e scenarios need a stack running with " + "AGENT_KV_CALCULATIONS_ENABLED=true and the sandbox codegen " + f"worker fleet up (docs/agent-kv-api.md §11); set {_CALC_ENV_GATE}=1 " + "to declare that this stack is configured that way" + ) + + +def test_concurrency_limit_returns_429(agent_kv_key: AgentKVAuth) -> None: + """Saturate the org's concurrency limiter; every other submit-based + scenario in this module runs before this one, and ``tests/groups.yaml`` + pins this whole group ``parallel: false`` -- both for the same reason: + + ``JobCancelView`` marks a job ``CANCELLED`` directly via + ``AgentKVJob.mark_terminal`` and never touches + ``AgentKVConcurrencyLimiter`` -- only a job's own *finalize* call + releases its slot (in a ``finally``, unconditional on whether the + terminal-state guard actually won -- docs/agent-kv-api.md §11a), and + that only happens once the executor that picked the job up actually + runs it to completion/failure and calls back. So immediately after this + test oversaturates the limiter, the org's concurrency budget stays + genuinely exhausted for however long those accepted jobs take to + finalize -- anywhere from seconds to the executor's task time limit. + Any sibling submit-based test running concurrently with, or soon after, + this one would risk being 429'd by *this* test's saturation rather than + by anything it did itself. Running this scenario last (and the group + serially) removes that risk entirely for this module; the cleanup below + is a courtesy for whatever runs against this org after it, not a + correctness requirement for this test itself -- the limiter's own 6h + slot TTL (``AgentKVConcurrencyLimiter._SLOT_TTL_SECONDS``) is the + ultimate backstop either way. + """ + limit = int(os.environ.get("AGENT_KV_CONCURRENT_LIMIT", "5")) + n = limit + 1 + + def _submit(_i: int): + return _cheap_submit(agent_kv_key) + + accepted_job_ids: list[str] = [] + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=n) as pool: + responses = list(pool.map(_submit, range(n))) + + statuses = [r.status_code for r in responses] + assert all(s in (202, 429) for s in statuses), ( + f"unexpected status among {n} concurrent submits " + f"(only 202/429 are valid outcomes here): {statuses}" + ) + assert 429 in statuses, ( + f"submitting {n} concurrent jobs against a limit of {limit} " + f"produced no 429: {statuses}" + ) + for r in responses: + if r.status_code == 202: + accepted_job_ids.append(r.json()["job_id"]) + assert len(accepted_job_ids) <= limit, accepted_job_ids + finally: + # Cancelling stops each job's eventual result from ever being + # readable, but -- per the docstring above -- does NOT itself free + # its concurrency slot; only that job's own finalize call does. + for job_id in accepted_job_ids: + try: + cancel(agent_kv_key, job_id) + except Exception: # noqa: BLE001 - cleanup only, never fail the test on it + pass + + # Best-effort drain: keep probing with a cheap submission until one + # is actually accepted again (proof a slot freed up) or the bounded + # wait runs out. The RESULT is deliberately ignored here -- see the + # 6h-TTL backstop note in the docstring above; this is a courtesy for + # whatever runs next, not a correctness requirement for this test. + _drain_until_slot_free(agent_kv_key) diff --git a/tests/groups.yaml b/tests/groups.yaml index cc2e5beb73..652c4885c9 100644 --- a/tests/groups.yaml +++ b/tests/groups.yaml @@ -89,7 +89,8 @@ groups: unit-workers: tier: unit workdir: workers - paths: [tests, shared/tests] + paths: [tests, shared/tests, shared/infrastructure/config/tests, + ide_callback/tests] # Negative filter: worker unit tests aren't tagged `unit` (~1100 of them), # so select by exclusion. `conftest.py` marks real-Postgres tests # `integration` (routed to the `integration-workers` group); `slow` tests are @@ -128,6 +129,25 @@ groups: install_editable: true coverage_source: src + # Both of these had tests but NO group, so nothing collected them. For + # agent-kv-schema that mattered specifically: test_compile.py is the + # regression net for the schema depth/ReDoS caps, so the caps were asserted + # only on a developer's machine. A PR reporting "165 passed" for this package + # was reporting a local figure, not a CI one. + unit-agent-kv-schema: + tier: unit + workdir: unstract/agent-kv-schema + paths: [tests] + install_editable: true + coverage_source: src + + unit-filesystem: + tier: unit + workdir: unstract/filesystem + paths: [tests] + install_editable: true + coverage_source: src + # runner/tests was registered in NO group, so it was collected by nothing. Its # only suite guards that LOG_TRANSPORT and LOG_STREAM_QUEUE_NAME reach the tool # sidecar's hand-picked env allowlist — a list that drops anything not named in @@ -254,6 +274,29 @@ groups: depends_on: [e2e-smoke] optional: true + # Agent-KV product API (/agent-kv/...). Needs the cloud `agentic_kv` + # executor plugin installed (docs/agent-kv-api.md §11) -- an OSS-only image + # 501s every submit, so this is optional: an OSS-only CI run must not go + # red just because the plugin isn't there. The module itself additionally + # self-gates on AGENT_KV_E2E=1 so it never runs by accident even when the + # platform happens to be up (see tests/e2e/agent_kv/test_agent_kv_e2e.py). + # Longer budget than the other platform groups: the happy-path and delete + # scenarios poll a real extraction job through to COMPLETED. + e2e-agent-kv: + tier: e2e + paths: [tests/e2e/agent_kv] + requires_platform: true + depends_on: [e2e-smoke] + optional: true + timeout_seconds: 1200 + # Serial, not xdist-parallel: the concurrency scenario deliberately + # saturates the org's AgentKVConcurrencyLimiter slots, which + # JobCancelView never releases (only a finalize callback does, by + # design -- see the scenario's own docstring). Running sibling + # submit-based tests on other xdist workers at the same moment would + # let that saturation 429 them too. + parallel: false + ui: # Playwright browser e2e over the real UI. Scaffolding only for now — the # spec directory holds a single smoke test; extend it here. diff --git a/tox.ini b/tox.ini index b1d66781a9..cd4bc9c8fd 100644 --- a/tox.ini +++ b/tox.ini @@ -52,6 +52,11 @@ passenv = GITHUB_* DOCKER_HOST UNSTRACT_* + # The Agent-KV e2e lane's stack config (engine keys/models, storage prefix, + # the caps its scenarios override) is read by + # tests/compose/docker-compose.test.yaml from the caller's environment; tox + # would otherwise strip it and the lane would silently run misconfigured. + AGENT_KV_* # ComposeRuntime's `docker compose up` resolves ${VERSION} image tags in # docker/docker-compose.yaml from the caller's environment. VERSION diff --git a/unstract/agent-kv-schema/pyproject.toml b/unstract/agent-kv-schema/pyproject.toml new file mode 100644 index 0000000000..9db37d39b7 --- /dev/null +++ b/unstract/agent-kv-schema/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "unstract-agent-kv-schema" +version = "0.1.0" +description = "Agent-KV keys.json schema compiler — single source of truth for API validation and the extraction engine" +requires-python = ">=3.12" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/unstract"] diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/__init__.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/__init__.py new file mode 100644 index 0000000000..07087e3f47 --- /dev/null +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/__init__.py @@ -0,0 +1,15 @@ +from .compile import CompiledSchema, SchemaCaps, SchemaError, compile_schema +from .constraints import evaluate_constraints +from .dataclasses import ArraySpec, KeySpec +from .validators import validate_format + +__all__ = [ + "ArraySpec", + "CompiledSchema", + "KeySpec", + "SchemaCaps", + "SchemaError", + "compile_schema", + "evaluate_constraints", + "validate_format", +] diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py new file mode 100644 index 0000000000..6b28c3b07f --- /dev/null +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py @@ -0,0 +1,265 @@ +"""Cap-enforcing, syntax-validating wrapper over the ported compiler. + +This is the entry point the API uses for submit-time validation. Anything +``compile_schema`` accepts, the engine must execute; anything it rejects never +reaches OCR or an LLM. + +It is NOT, despite what this docstring said before, "the single entry point +both the API and the cloud engine use". The engine calls the raw +``kv_schema.compile``/``compile_arrays`` directly (see the import note in the +cloud plugin's ``engine/kv_extractor.py``), so the caps below are enforced on +the submit path only. That is sound while the backend is the sole producer of a +compiled schema -- which it is today -- but it means the caps are a gate, not +an invariant the engine itself re-checks. Anything that ever hands the engine a +schema from another source has to apply them, or this module has to become what +it claimed to be. +""" + +import ast +import re +from dataclasses import dataclass, field + +from . import kv_schema +from .dataclasses import ArraySpec, KeySpec + + +class SchemaError(ValueError): + """User-facing schema rejection; message is safe to return in a 400.""" + + +@dataclass(frozen=True) +class SchemaCaps: + max_leaves: int = 200 + max_arrays: int = 20 + max_columns_per_array: int = 40 + max_depth: int = 6 + max_regex_len: int = 200 + max_aliases: int = 10 + max_description_len: int = 500 + max_constraints: int = 30 + + +@dataclass(frozen=True) +class CompiledSchema: + key_specs: list[KeySpec] = field(default_factory=list) + array_specs: list[ArraySpec] = field(default_factory=list) + constraints: list[str] = field(default_factory=list) + + +_ALLOWED_CALLS = {"sum", "count", "min", "max", "avg"} +_ALLOWED_NODES = ( + ast.Expression, + ast.BoolOp, + ast.And, + ast.Or, + ast.UnaryOp, + ast.Not, + ast.USub, + ast.Compare, + ast.Eq, + ast.NotEq, + ast.Lt, + ast.LtE, + ast.Gt, + ast.GtE, + ast.BinOp, + ast.Add, + ast.Sub, + ast.Mult, + ast.Div, + ast.Call, + ast.Name, + ast.Attribute, + ast.Constant, + ast.Load, +) + + +def _check_constraint_syntax(expr: str) -> None: + """Static allowlist mirroring constraints._evaluate_one's grammar.""" + try: + tree = ast.parse(expr, mode="eval") + except SyntaxError as e: + raise SchemaError(f"constraint does not parse: {expr!r} ({e.msg})") from e + for node in ast.walk(tree): + if not isinstance(node, _ALLOWED_NODES): + raise SchemaError( + f"constraint uses disallowed syntax ({type(node).__name__}): {expr!r}" + ) + if isinstance(node, ast.Call): + if not isinstance(node.func, ast.Name) or node.func.id not in _ALLOWED_CALLS: + raise SchemaError(f"constraint calls a disallowed function: {expr!r}") + if ( + node.keywords + or len(node.args) != 1 + or not isinstance(node.args[0], ast.Constant) + or not isinstance(node.args[0].value, str) + ): + raise SchemaError( + f"constraint aggregate needs one string literal arg: {expr!r}" + ) + + +def _max_depth(node: object, max_depth: int, depth: int = 0) -> int: + # HARD ceiling FIRST -- before the `_array` short-circuit and before any + # further recursion. Two bugs this closes: (a) a pathologically deep plain + # schema (256 KiB of JSON nests ~40k deep, far past Python's ~1000 + # recursion limit) used to raise an uncaught RecursionError (500) here; + # now it fails fast as a clean SchemaError (400). (b) The `_array` + # short-circuit below cannot be reached to bypass the cap once `depth` + # has already blown past it. + if depth > max_depth: + raise SchemaError(f"schema exceeds max_depth={max_depth}") + if not isinstance(node, dict): + return depth + if "_array" in node: + return depth + 1 # array columns are row-local, not nesting + child = [v for v in node.values() if isinstance(v, dict)] + if not child: + return depth + 1 + return max(_max_depth(v, max_depth, depth + 1) for v in child) + + +# Quantified groups that themselves contain a quantifier, e.g. `(a+)+`, `(a*)*`, +# `(.+)*`, `(?:x+)+`. This is the shape behind catastrophic backtracking. +_NESTED_QUANTIFIER = re.compile( + r"\((?:\?[:=!]|\?<[=!]|\?P<[^>]+>)?" # group open, incl. non-capturing/named + r"[^()]*[+*}]\??" # ...containing a quantifier + r"[^()]*\)" # ...group close + r"\s*[+*]|\)\s*\{\d+,\d*\}" # ...itself quantified +) + + +# A quantified group whose alternatives OVERLAP, e.g. `(a|aa)+`. Found in +# review after the nested-quantifier check shipped: `^(a|aa)+$` passes that +# check and still backtracks catastrophically, because at each position the +# engine can consume one `a` or two and must try both on failure. +# +# Only LITERAL branches are compared, and only by the prefix relation: if one +# branch is a prefix of another (`a` of `aa`), the group is ambiguous and +# refused. `(foo|bar)+` is left alone -- distinct first characters mean no +# position admits two parses, so it is linear. Branches containing +# metacharacters are not analysed (`.`/classes/nested groups need real regex +# analysis, which is what UN-4225 is for); this closes the demonstrated family +# without pretending to be a decision procedure. +_QUANTIFIED_GROUP = re.compile(r"\((\?:)?([^()]*)\)\s*(?:[+*]|\{\d+,\d*\})") +_LITERAL_BRANCH = re.compile(r"^[\w\-/ ]*$") + + +def _overlapping_alternation(pattern: str) -> str | None: + """Return a human-readable overlap if a quantified group is ambiguous.""" + for match in _QUANTIFIED_GROUP.finditer(pattern): + body = match.group(2) + if "|" not in body: + continue + branches = body.split("|") + if not all(_LITERAL_BRANCH.match(b) for b in branches): + continue + for i, a in enumerate(branches): + for j, b in enumerate(branches): + if i != j and a and b.startswith(a): + return f"{a!r} is a prefix of {b!r}" + return None + + +def _reject_unsafe_regex(path: str, pattern: str) -> None: + """Refuse an author-supplied pattern at SUBMIT rather than at match time. + + Two separate problems, both found in review: + + 1. The pattern was never compiled here, so a syntactically invalid one was + accepted and only discovered per-value in the engine's QA pass -- where + ``_check_one`` swallows ``re.error`` and returns True, silently passing + validation the author thought they had configured. + 2. ``validate_format`` runs the pattern against extracted values with no + time budget, so a catastrophically-backtracking pattern is a DoS. The + length cap is NOT a mitigation: ``^(a+)+$`` is 7 characters and takes + ~1.9s on 26 ``a``s, ~4x per character added (measured), so a 40-char + value runs for hours. With ``AGENT_KV_CONCURRENT_LIMIT=5`` one org can + pin five shared worker slots from a single submit. + + The nested-quantifier check is a CONSERVATIVE HEURISTIC, not a proof. It + rejects the shape responsible for the realistic cases (a quantified group + whose body is itself quantified) and will reject some safe patterns that + happen to look like it -- an explicit trade, since the author gets an + immediate, actionable error instead of a job that hangs. It does not catch + every pathological pattern; the complete fix is a linear-time engine (RE2), + which cannot be added here because this package deliberately has zero + dependencies and is installed by both repos. Tracked as UN-4225. + """ + try: + re.compile(pattern) + except re.error as e: + raise SchemaError(f"'{path}' has an invalid regex: {e}") from None + if _NESTED_QUANTIFIER.search(pattern): + raise SchemaError( + f"'{path}' regex has a nested quantifier (e.g. '(a+)+'), which can " + "backtrack catastrophically and stall extraction. Rewrite it " + "without a quantifier inside a quantified group." + ) + overlap = _overlapping_alternation(pattern) + if overlap: + raise SchemaError( + f"'{path}' regex quantifies a group whose alternatives overlap " + f"({overlap}), e.g. '(a|aa)+', which can backtrack " + "catastrophically and stall extraction. Make the alternatives " + "mutually exclusive, or use a character class." + ) + + +def compile_schema(spec: dict, caps: SchemaCaps | None = None) -> CompiledSchema: + caps = caps or SchemaCaps() + if not isinstance(spec, dict): + raise SchemaError("Top-level key schema must be a JSON object") + cleaned = {k: v for k, v in spec.items() if k != "_constraints"} + if _max_depth(cleaned, caps.max_depth) > caps.max_depth: + raise SchemaError(f"schema exceeds max_depth={caps.max_depth}") + # The compile.py `_max_depth` pre-check does not count array-column + # nesting (arrays are row-local there, by design) and cannot see a decoy + # top-level `_array` field's real nesting -- so the actual recursive walk + # (`kv_schema._walk`) carries its own max_depth ceiling too, both to close + # that bypass and to guarantee a clean SchemaError instead of an uncaught + # RecursionError on a deeply-nested input. + try: + key_specs = kv_schema.compile(spec, max_depth=caps.max_depth) + array_specs = kv_schema.compile_arrays(spec, max_depth=caps.max_depth) + except ValueError as e: + raise SchemaError(str(e)) from e + + if len(key_specs) > caps.max_leaves: + raise SchemaError(f"schema exceeds max_leaves={caps.max_leaves}") + if len(array_specs) > caps.max_arrays: + raise SchemaError(f"schema exceeds max_arrays={caps.max_arrays}") + for aspec in array_specs: + if len(aspec.item_specs) > caps.max_columns_per_array: + raise SchemaError( + f"array '{aspec.path}' exceeds " + f"max_columns_per_array={caps.max_columns_per_array}" + ) + for kspec in key_specs + [s for a in array_specs for s in a.item_specs]: + _reject_unsafe_regex(kspec.path, kspec.regex_pattern) + if len(kspec.regex_pattern) > caps.max_regex_len: + raise SchemaError( + f"'{kspec.path}' regex exceeds max_regex_len={caps.max_regex_len}" + ) + if len(kspec.aliases) > caps.max_aliases: + raise SchemaError(f"'{kspec.path}' exceeds max_aliases={caps.max_aliases}") + if len(kspec.effective_description) > caps.max_description_len: + raise SchemaError( + f"'{kspec.path}' description exceeds " + f"max_description_len={caps.max_description_len}" + ) + + constraints = spec.get("_constraints", []) + if not isinstance(constraints, list) or not all( + isinstance(c, str) for c in constraints + ): + raise SchemaError("_constraints must be a list of strings") + if len(constraints) > caps.max_constraints: + raise SchemaError(f"schema exceeds max_constraints={caps.max_constraints}") + for expr in constraints: + _check_constraint_syntax(expr) + + return CompiledSchema( + key_specs=key_specs, array_specs=array_specs, constraints=list(constraints) + ) diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py new file mode 100644 index 0000000000..85efa58b2e --- /dev/null +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py @@ -0,0 +1,258 @@ +"""Cross-field consistency (spec §18.3): a fail-closed evaluator for schema-author-declared +constraints over the NORMALIZED key values. No eval/exec — a static AST allowlist (Compare/BoolOp/ +BinOp/UnaryOp/Name/Attribute-path/Constant only). Operands resolve to normalized values; a missing/ +empty/un-coercible operand SKIPS the constraint (advisory), never crashes. Returns violated exprs. +""" + +import ast +import math +import operator + +from .validators import coerce_number + +# Equality on these values is TOLERANT, not exact. Operands are normalized +# currency/number values that arrived as floats, so binary floating point makes +# exact `==` wrong for the thing constraints exist to express. The feature's own +# headline example reports a false violation on a CORRECT invoice: +# three line items of 8230.4 sum to 24691.199999999997, which `operator.eq` +# says is not 24691.2 (reproduced). +# +# The tolerance has to sit BELOW a cent at every realistic magnitude while +# staying ABOVE the float noise floor. Those two bounds are what set the +# numbers, and the first attempt at this got it wrong: `rel_tol=1e-9` is 0.1 at +# a total of $100,000,000, so it silently absorbed a one-cent reconciliation +# error on a large invoice -- the exact failure this check exists to catch +# (found in review; the original test only covered a ~$25,000 total). +# +# Noise floor: summing N values of magnitude M accumulates roughly +# N * 2.2e-16 * M of float error. M=$1e8 over 1,000 rows is ~2.2e-5, i.e. about +# a five-hundredth of a cent. +# +# rel_tol=1e-12 -> 1e-4 at $1e8 (a hundredth of a cent): absorbs that noise, +# and a one-cent error is 100x larger, so it is still +# reported. Relative rather than absolute-only so the bound +# tracks magnitude -- an absolute epsilon suited to invoice +# totals is meaningless against unit prices in thousandths. +# abs_tol=1e-6 -> keeps comparisons against exact zero working, where +# relative tolerance is useless (every non-zero value is +# infinitely far from 0 in relative terms), and is itself +# well under a cent. +# +# Known ceiling: past ~$1e12 with ~10,000 rows the noise floor (~2.2) exceeds a +# cent and no float tolerance can separate the two. That is a float problem, +# not a tolerance problem, and the fix is Decimal end to end -- +# `normalizers.coerce_number`, UN-4226. This makes the COMPARISON correct for +# the float values that exist today, and stays correct afterwards. +_REL_TOL = 1e-12 +_ABS_TOL = 1e-6 + + +def _num_eq(a, b) -> bool: + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + return math.isclose(a, b, rel_tol=_REL_TOL, abs_tol=_ABS_TOL) + return operator.eq(a, b) + + +def _num_ne(a, b) -> bool: + return not _num_eq(a, b) + + +_CMP = { + ast.Eq: _num_eq, + ast.NotEq: _num_ne, + # Ordering comparisons are left exact on purpose: a tolerant `<` would make + # `a < b` and `a == b` both true at the boundary, and the failure mode these + # express (a total that is too large/small) is not a rounding artefact. + ast.Lt: operator.lt, + ast.LtE: operator.le, + ast.Gt: operator.gt, + ast.GtE: operator.ge, +} +_BIN = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, +} + +# P8c: the ONLY callable names permitted in a constraint, and only over a single +# string-literal `'array_path'` / `'array_path.column'` argument. This is NOT a general +# function-call capability — it is a closed allowlist of pure numeric reductions evaluated +# in Python (never via eval/exec) so a scalar key can be reconciled against an array column +# (e.g. grand_total == sum('line_items.line_total')). +_AGG = {"sum", "count", "min", "max", "avg"} + + +class _Skip(Exception): + """Operand missing/empty/incomparable — skip this constraint (advisory).""" + + +def _path(node) -> str: + """Reconstruct a dotted path from a Name / Attribute-chain (pure attribute access only).""" + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return f"{_path(node.value)}.{node.attr}" + raise _Skip() + + +def _coerce(raw: str): + """Normalized value -> finite float when numeric, else the string (ISO dates compare + lexicographically). Uses the canonical coercer so thousands-separated values ('9,000') + parse numerically (a bare float() would leave them as strings and silently drop a real + violation via the like-type guard); 'nan'/'inf' tokens stay strings (not comparable numbers). + """ + n = coerce_number( + raw + ) # strips commas/$/%; returns None for non-numeric AND non-finite + return n if n is not None else raw + + +def _aggregate(node: ast.Call, arrays: dict[str, list[dict[str, str]]]): + """Evaluate one of the five allowlisted aggregates over an array column. + + FAIL-CLOSED by construction: the only thing this accepts is `NAME('literal')` where + NAME is in `_AGG`, the func is a bare ast.Name (no attribute access — blocks + `os.system`/`x.__class__`), there is exactly one positional arg, no keywords, and that + arg is a string Constant. Any deviation raises `_Skip` (-> constraint skipped, never run). + + Argument is `'array_path'` (only valid for count = row count) or `'array_path.column'`. + Numeric cells (sum/min/max/avg) are pulled with the SAME `coerce_number` used elsewhere + (strips commas/$/%, drops non-finite/empty -> None); such cells are skipped (not zeroed). + `count('a.col')` counts rows whose column value is NON-EMPTY (text columns like + sku/description count too); `count('a')` is the row count. A sum/min/max/avg over zero + usable cells raises `_Skip` (advisory) rather than guessing 0. + """ + if not isinstance(node.func, ast.Name) or node.func.id not in _AGG: + raise _Skip() # not a whitelisted aggregate name + if node.keywords or len(node.args) != 1: + raise _Skip() # exactly one positional arg, no keywords + arg = node.args[0] + if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)): + raise _Skip() # arg must be a literal string path + fn, ref = node.func.id, arg.value + # Split on the LAST dot: an ArraySpec.path may itself be dotted (e.g. 'invoice.lines'), + # while the column is always a row-LOCAL bare name. A bare ref (no dot) is a whole-array + # row count -> array_path=ref, column=''. + if "." in ref: + array_path, _, column = ref.rpartition(".") + else: + array_path, column = ref, "" + rows = arrays.get(array_path) + if rows is None: # no such array available + raise _Skip() + + if fn == "count": + if not column: # count('array') -> number of rows + return float(len(rows)) + # count('array.col') -> rows with a non-empty value for that column (text columns + # like sku/description/name are valid to count; numeric coercion would zero them out) + return float(sum(1 for r in rows if (r.get(column) or "").strip() != "")) + + if not column: # sum/min/max/avg need a column + raise _Skip() + nums = [n for r in rows if (n := coerce_number(r.get(column))) is not None] + if not nums: # nothing usable -> advisory skip + raise _Skip() + # `math.fsum`, not the builtin `sum`: exactly-rounded, so accumulation error + # does not grow with row count. The builtin accumulates left to right, and + # on a long array that drift is the one thing that could push a CORRECT + # total outside the comparison tolerance and report a false violation. + # + # Review raised exactly that risk for a 100,000-row array. It did not + # reproduce -- 60 randomised value/row combinations up to 100,000 rows and + # ~$100k per line produced zero false positives, and the specific figure + # quoted (`sum([8230.40] * 100_000) == 823039999.9985306`) is not what + # CPython computes; it is exactly 823040000.0, because for identical + # addends the partial sums stay representable. But "does not reproduce + # across 60 samples" is a weaker guarantee than "cannot happen", the fix + # costs one function call, and this is the third review pass over these few + # lines -- so the error source is removed rather than argued about. + if fn == "sum": + return float(math.fsum(nums)) + if fn == "min": + return float(min(nums)) + if fn == "max": + return float(max(nums)) + return float(math.fsum(nums) / len(nums)) # avg + + +def _operand(node, values: dict[str, str], arrays: dict[str, list[dict[str, str]]]): + if isinstance(node, ast.Call): + return _aggregate(node, arrays) # ONLY the _AGG allowlist; else _Skip + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.BinOp) and type(node.op) in _BIN: + a, b = _operand(node.left, values, arrays), _operand(node.right, values, arrays) + if not (isinstance(a, (int, float)) and isinstance(b, (int, float))): + raise _Skip() + return _BIN[type(node.op)](a, b) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + v = _operand(node.operand, values, arrays) + if not isinstance(v, (int, float)): + raise _Skip() + return -v + # else: a key reference + path = _path(node) + raw = values.get(path) + if raw is None or raw == "": + raise _Skip() + return _coerce(raw) + + +def _truth(node, values: dict[str, str], arrays: dict[str, list[dict[str, str]]]) -> bool: + if isinstance(node, ast.BoolOp): + sub = [_truth(v, values, arrays) for v in node.values] + return all(sub) if isinstance(node.op, ast.And) else any(sub) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): + return not _truth(node.operand, values, arrays) + if isinstance(node, ast.Compare) and len(node.ops) == len(node.comparators): + left = _operand(node.left, values, arrays) + for op, comp in zip(node.ops, node.comparators, strict=False): + if type(op) not in _CMP: + raise _Skip() + right = _operand(comp, values, arrays) + # only compare like-typed operands (number<->number, str<->str); else skip + if isinstance(left, (int, float)) != isinstance(right, (int, float)): + raise _Skip() + if not _CMP[type(op)](left, right): + return False + left = right + return True + raise _Skip() + + +def _evaluate_one( + expr: str, values: dict[str, str], arrays: dict[str, list[dict[str, str]]] +): + """Return True/False, or None to skip (missing operand / unsupported / unsafe).""" + try: + tree = ast.parse(expr, mode="eval") + except SyntaxError: + return None + try: + return _truth(tree.body, values, arrays) + except _Skip: + return None + except Exception: + return None # fail-closed: any surprise -> skip, never crash the pipeline + + +def evaluate_constraints( + constraints: list[str], + values: dict[str, str], + arrays: dict[str, list[dict[str, str]]] | None = None, +) -> list[str]: + """Return the list of constraint expressions that evaluated to False (violations). + Skipped (missing operand / unsupported / unsafe) constraints are NOT violations. + + `arrays` (optional) maps an ArraySpec path to its rendered rows (list of {column: value}) + so the five allowlisted aggregates (sum/count/min/max/avg) can reconcile a scalar key + against an array column. Defaults to {} -> aggregates become no-op skips (back-compat). + """ + arrays = arrays or {} + violations = [] + for expr in constraints or []: + if _evaluate_one(expr, values, arrays) is False: + violations.append(expr) + return violations diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/dataclasses.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/dataclasses.py new file mode 100644 index 0000000000..10b72ef1b2 --- /dev/null +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/dataclasses.py @@ -0,0 +1,53 @@ +"""Dataclasses for the key-value extractor. + +ExecutionResult is intentionally NOT defined here — code execution reuses the +imported src/core code_executor's result type (see spec §4, §10). +""" + +from dataclasses import dataclass, field + + +@dataclass +class KeySpec: + """One compiled leaf key from the user's nested key schema.""" + + path: str # dotted path, e.g. "vendor.address.city" + effective_description: str # breadcrumb + leaf description + format: str = "string" # kind: string|number|date|currency|enum|regex| + enum_values: list[str] = field(default_factory=list) # set when format == "enum" + regex_pattern: str = "" # set when format == "regex" + required: bool = False + aliases: list[str] = field(default_factory=list) + multivalued: bool = False # value is a comma-separated string + + +@dataclass +class ArraySpec: + """One compiled FLAT-array node (P8a). `path` = dotted array location (e.g. 'line_items', + 'invoice.lines'). `item_specs` = the declared columns as row-LOCAL scalar KeySpecs (their + `path` is the bare column name). `key_column` (optional) is a column used for row identity in + scoring; '' = positional. Nested arrays inside an item are P8b and rejected at compile. + """ + + path: str + description: str = "" + item_specs: list[KeySpec] = field(default_factory=list) + key_column: str = "" + required: bool = False + #: Collapse rows identical in EVERY extracted cell? Declared per array as + #: `"_dedup": false`. + #: + #: Defaults True, which is the pre-existing behaviour and is wanted for the + #: corpus the extractor was built against: layout-preserving OCR repeats a + #: label once per replicate column, producing rows identical in every cell. + #: + #: But it is NOT lossless, and the docstring that claimed it was has been + #: corrected. A document that genuinely contains two identical line items + #: comes back with one, so the row count and any calculation summing or + #: counting the rows are wrong -- and the codegen path consumes exactly + #: these rows. Being unable to tell two real records from one OCR artefact + #: is a reason not to guess, so a schema author who knows their documents + #: can say so. Default left at True rather than flipped: changing it would + #: trade a known-wrong case for an untested one on the corpus this was + #: built for. + dedup_rows: bool = True diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/kv_schema.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/kv_schema.py new file mode 100644 index 0000000000..93cedeb508 --- /dev/null +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/kv_schema.py @@ -0,0 +1,224 @@ +"""Compile a user-authored nested key schema into a flat, ordered KeySpec list, +and reassemble flat extracted values back into the nested shape. + +Leaf/interior rule (spec §7): a node is INTERIOR iff ALL of its values are objects +(its keys are child nodes); otherwise it is a LEAF defined by its scalar attributes. +A node mixing object and scalar values is a compile-time error. Reserved attribute +words cannot be used as child-node key names. +""" + +from typing import Any + +from .dataclasses import ArraySpec, KeySpec + +RESERVED = {"description", "format", "required", "aliases", "multivalued"} +RESERVED_NODE = { + "_array", + "description", + "_key", + "_dedup", +} # node-level (distinct from leaf-attr RESERVED) + +# Absolute structural-depth ceiling for callers that don't pass an explicit +# max_depth (the public compile/compile_arrays entry points). It only has to +# sit well below Python's ~1000 recursion limit so a hostile keys.json fails +# fast as a clean ValueError (-> SchemaError) instead of a RecursionError; +# compile_schema always passes the real, much smaller cap from SchemaCaps. +_DEFAULT_MAX_DEPTH = 100 + + +def _parse_dedup(raw: Any, path: str) -> bool: + """Validate the per-array `_dedup` flag. + + Strict about the type rather than truthy: `"_dedup": "false"` is a string + and would be TRUE under a truthiness check, silently leaving dedup on for + an author who explicitly asked for it off -- and silently dropping their + duplicate rows. A wrong type is a schema error, not a default. + """ + if isinstance(raw, bool): + return raw + raise ValueError(f"'{path}' _dedup must be true or false (got {type(raw).__name__})") + + +def _parse_format(raw: str): + """Return (kind, enum_values, regex_pattern) for a declared format string.""" + if raw.startswith("enum:"): + values = [v.strip() for v in raw[len("enum:") :].split(",") if v.strip()] + return "enum", values, "" + if raw.startswith("regex:"): + return "regex", [], raw[len("regex:") :] + return raw, [], "" + + +def _walk( + node: dict[str, Any], + path_parts: list[str], + out: list[KeySpec], + arrays: list[ArraySpec], + *, + max_depth: int = _DEFAULT_MAX_DEPTH, + depth: int = 0, +) -> None: + # HARD structural-depth ceiling at the TOP of the recursive walk. This is + # the real backstop the compile.py `_max_depth` pre-check can't provide: + # that check treats array columns as row-local (not nesting) and is blind + # to a decoy top-level `_array` field's real nesting, so a schema can pass + # it yet still recurse arbitrarily deep here. `depth` increments on EVERY + # recursion (interior children AND array columns), so exceeding the cap + # raises a clean ValueError -- surfaced as SchemaError by compile_schema -- + # instead of an uncaught RecursionError. + if depth > max_depth: + raise ValueError(f"schema exceeds max_depth={max_depth}") + if not isinstance(node, dict) or not node: + raise ValueError( + f"Schema node at {'.'.join(path_parts) or ''} must be a non-empty object" + ) + + if "_array" in node: + extra = set(node.keys()) - RESERVED_NODE + if extra: + raise ValueError( + f"Array node at {'.'.join(path_parts) or ''} has unexpected keys {sorted(extra)}; " + f"allowed: {sorted(RESERVED_NODE)}" + ) + item_schema = node["_array"] + if not isinstance(item_schema, dict) or not item_schema: + raise ValueError( + f"Array item schema at {'.'.join(path_parts)} must be a non-empty object" + ) + item_specs: list[KeySpec] = [] + for col_name, col_node in item_schema.items(): + if isinstance(col_node, dict) and "_array" in col_node: + raise ValueError( + f"Nested array at {'.'.join(path_parts)}.{col_name} is P8b (not supported in P8a)" + ) + _walk( + col_node, + [col_name], + item_specs, + [], # row-LOCAL paths; nested arrays sink to [] (rejected above) + max_depth=max_depth, + depth=depth + 1, + ) + arrays.append( + ArraySpec( + path=".".join(path_parts), + description=str(node.get("description", "")), + item_specs=item_specs, + key_column=str(node.get("_key", "")), + dedup_rows=_parse_dedup(node.get("_dedup", True), ".".join(path_parts)), + ) + ) + return + + object_values = [v for v in node.values() if isinstance(v, dict)] + scalar_keys = [k for k, v in node.items() if not isinstance(v, dict)] + + is_interior = len(object_values) == len(node) # every value is an object + is_leaf = len(object_values) == 0 # no value is an object + + if not is_interior and not is_leaf: + raise ValueError( + f"Schema node at {'.'.join(path_parts) or ''} mixes object children " + f"and scalar attributes; an interior node's values must all be objects" + ) + + if is_interior: + for child_name, child_node in node.items(): + _walk( + child_node, + path_parts + [child_name], + out, + arrays, + max_depth=max_depth, + depth=depth + 1, + ) + return + + # Leaf node: scalar attributes only. + unknown = [k for k in scalar_keys if k not in RESERVED] + if unknown: + raise ValueError( + f"Leaf at {'.'.join(path_parts)} has unknown attribute(s) {unknown}; " + f"allowed: {sorted(RESERVED)}" + ) + if "description" not in node: + raise ValueError(f"Leaf at {'.'.join(path_parts)} is missing a 'description'") + + kind, enum_values, regex_pattern = _parse_format(str(node.get("format", "string"))) + breadcrumb = " > ".join(path_parts) + out.append( + KeySpec( + path=".".join(path_parts), + effective_description=f"{breadcrumb}: {node['description']}", + format=kind, + enum_values=enum_values, + regex_pattern=regex_pattern, + required=bool(node.get("required", False)), + aliases=list(node.get("aliases", [])), + multivalued=bool(node.get("multivalued", False)), + ) + ) + + +def _compile_both(spec_json: dict[str, Any], max_depth: int = _DEFAULT_MAX_DEPTH): + if not isinstance(spec_json, dict): + raise ValueError("Top-level key schema must be a JSON object") + out: list[KeySpec] = [] + arrays: list[ArraySpec] = [] + for top_name, top_node in spec_json.items(): + if top_name == "_constraints": + continue + _walk(top_node, [top_name], out, arrays, max_depth=max_depth) + return out, arrays + + +def compile( + spec_json: dict[str, Any], max_depth: int = _DEFAULT_MAX_DEPTH +) -> list[KeySpec]: + """Compile to an ordered flat list of SCALAR leaf KeySpecs (array nodes excluded).""" + return _compile_both(spec_json, max_depth)[0] + + +def compile_arrays( + spec_json: dict[str, Any], max_depth: int = _DEFAULT_MAX_DEPTH +) -> list[ArraySpec]: + """Compile the top-level/interior-nested array nodes to an ordered list of ArraySpecs.""" + return _compile_both(spec_json, max_depth)[1] + + +def reassemble( + values: dict[str, Any], + specs: list[KeySpec], + arrays: list[ArraySpec] = None, + array_values: dict[str, Any] = None, +) -> dict[str, Any]: + """Rebuild the nested dict from flat {dotted_path: value}. + + Iterates `specs` (so output key order follows the schema) and places each + present value at its dotted path. Multi-valued leaves are comma-separated + strings and pass through verbatim. Paths absent from `values` are skipped. + + Arrays (P8a): for each ArraySpec in `arrays`, place its pre-rendered + list-of-dicts (from `array_values[path]`) at its dotted path. Scalar-only + callers (arrays/array_values=None) behave identically to before. + """ + out: dict[str, Any] = {} + for spec in specs: + if spec.path not in values: + continue + parts = spec.path.split(".") + node = out + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = values[spec.path] + for aspec in arrays or []: + rows = (array_values or {}).get(aspec.path) + if rows is None: + continue + parts = aspec.path.split(".") + node = out + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = rows # list-of-dicts + return out diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/validators.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/validators.py new file mode 100644 index 0000000000..e05fabdd3b --- /dev/null +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/validators.py @@ -0,0 +1,115 @@ +"""Deterministic format/type validation for extracted values (QA, spec §9 check 2). + +An empty value passes here (presence is the separate completeness check). For a +multivalued leaf each comma-separated element is validated against the format. +""" + +import math +import re +from datetime import datetime + +from .dataclasses import KeySpec + +_NUMERIC_STRIP = re.compile(r"[,\s$%]") +_DATE_FORMATS = ( + "%m/%d/%Y", + "%m/%d/%y", + "%Y-%m-%d", + "%d/%m/%Y", + "%m-%d-%Y", + "%B %d, %Y", + "%b %d, %Y", + "%m/%Y", + "%Y", +) + + +def _is_number(value: str) -> bool: + cleaned = _NUMERIC_STRIP.sub("", value) + if cleaned in ("", "-", "."): + return False + try: + float(cleaned) + return True + except ValueError: + return False + + +def _is_date(value: str) -> bool: + v = value.strip() + for fmt in _DATE_FORMATS: + try: + datetime.strptime(v, fmt) + return True + except ValueError: + continue + return False + + +def _check_one(value: str, spec: KeySpec) -> bool: + fmt = spec.format + if fmt in ("number", "currency"): + return _is_number(value) + if fmt == "date": + return _is_date(value) + if fmt == "enum": + return value.strip().casefold() in {e.casefold() for e in spec.enum_values} + if fmt == "regex": + try: + return re.fullmatch(spec.regex_pattern, value.strip()) is not None + except re.error: + return True # a broken author-supplied pattern should not fail extraction + # "string" or any free-text hint: nothing to validate. + return True + + +def validate_format(value: str, spec: KeySpec) -> bool: + """True if `value` conforms to `spec.format`. Empty value -> True (completeness is + checked separately). Multivalued -> every element must conform. + """ + if value is None or value.strip() == "": + return True + if spec.multivalued: + elements: list[str] = [e.strip() for e in value.split(",") if e.strip()] + return all(_check_one(e, spec) for e in elements) if elements else True + return _check_one(value, spec) + + +def coerce_number(value: str) -> float | None: + """Parse a numeric/currency string to a float, or None. Strips commas/spaces/$/%.""" + if value is None: + return None + cleaned = _NUMERIC_STRIP.sub("", value) + if cleaned in ("", "-", "."): + return None + try: + n = float(cleaned) + except ValueError: + return None + # float() accepts 'inf'/'nan'/'1e400' as literals — reject non-finite so callers + # (output normalization, constraint comparison) never see inf/nan. + return n if math.isfinite(n) else None + + +def coerce_date(value: str) -> str | None: + """Parse a date string to ISO 'YYYY-MM-DD', or None.""" + if value is None: + return None + v = value.strip() + for fmt in _DATE_FORMATS: + try: + return datetime.strptime(v, fmt).strftime("%Y-%m-%d") + except ValueError: + continue + return None + + +def coerce_enum(value: str, spec: KeySpec) -> "str | None": + """Return the canonical `spec.enum_values` element matching `value` case-insensitively, or None.""" + if value is None: + return None + cf = value.strip().casefold() + for e in spec.enum_values: + if e.casefold() == cf: + return e + return None diff --git a/unstract/agent-kv-schema/tests/__init__.py b/unstract/agent-kv-schema/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/unstract/agent-kv-schema/tests/test_compile.py b/unstract/agent-kv-schema/tests/test_compile.py new file mode 100644 index 0000000000..ff3d4c7c10 --- /dev/null +++ b/unstract/agent-kv-schema/tests/test_compile.py @@ -0,0 +1,250 @@ +"""compile_schema: structural validation + caps on top of the ported compiler.""" + +import pytest + +from unstract.agent_kv_schema import ( + CompiledSchema, + SchemaCaps, + SchemaError, + compile_schema, +) + +VALID = { + "quotation_number": {"description": "The quote number", "required": True}, + "customer": {"name": {"description": "Bill-to name"}}, + "line_items": { + "description": "One row per line", + "_key": "sku", + "_array": { + "sku": {"description": "SKU"}, + "total": {"description": "Line total", "format": "currency"}, + }, + }, + "_constraints": ["count('line_items') >= 1"], +} + + +def test_valid_schema_compiles(): + out = compile_schema(VALID) + assert isinstance(out, CompiledSchema) + assert [s.path for s in out.key_specs] == ["quotation_number", "customer.name"] + assert out.array_specs[0].path == "line_items" + assert out.constraints == ["count('line_items') >= 1"] + + +def test_missing_description_is_schema_error(): + with pytest.raises(SchemaError, match="missing a 'description'"): + compile_schema({"a": {"format": "string"}}) + + +def test_mixed_node_is_schema_error(): + with pytest.raises(SchemaError, match="mixes object children"): + compile_schema({"a": {"description": "x", "b": {"description": "y"}}}) + + +def test_leaf_cap_enforced(): + spec = {f"k{i}": {"description": "d"} for i in range(5)} + caps = SchemaCaps(max_leaves=4) + with pytest.raises(SchemaError, match="max_leaves"): + compile_schema(spec, caps=caps) + + +def test_depth_cap_enforced(): + spec = {"a": {"b": {"c": {"d": {"description": "deep"}}}}} + caps = SchemaCaps(max_depth=3) + with pytest.raises(SchemaError, match="max_depth"): + compile_schema(spec, caps=caps) + + +def test_regex_length_cap(): + spec = {"a": {"description": "d", "format": "regex:" + "x" * 300}} + with pytest.raises(SchemaError, match="max_regex_len"): + compile_schema(spec) + + +def test_bad_constraint_syntax_rejected(): + spec = { + "a": {"description": "d"}, + "_constraints": ["__import__('os').system('true')"], + } + with pytest.raises(SchemaError, match="constraint"): + compile_schema(spec) + + +def test_constraint_call_allowlist(): + spec = {"a": {"description": "d"}, "_constraints": ["foo('a.b') > 1"]} + with pytest.raises(SchemaError, match="constraint"): + compile_schema(spec) + + +def test_constraints_cap(): + spec = {"a": {"description": "d"}, "_constraints": ["a > 0"] * 31} + with pytest.raises(SchemaError, match="max_constraints"): + compile_schema(spec) + + +def test_non_dict_top_level_rejected(): + with pytest.raises(SchemaError): + compile_schema(["not", "a", "dict"]) + + +# --------------------------------------------------------------------------- +# Depth-cap bypass + unbounded-recursion DoS (pre-Greptile critical #2). +# --------------------------------------------------------------------------- + + +def _nest(levels: int) -> dict: + """Build a plain interior schema `levels` deep ending in a leaf.""" + node = {"description": "deep"} + for i in range(levels): + node = {f"l{i}": node} + return node + + +def test_deeply_nested_raises_schema_error_not_recursion_error(): + # Well past max_depth but nowhere near Python's recursion limit: must be a + # clean SchemaError (400), never accepted. + deep, caps = _nest(30), SchemaCaps(max_depth=6) + with pytest.raises(SchemaError, match="max_depth"): + compile_schema(deep, caps=caps) + + +def test_decoy_array_key_cannot_bypass_depth_cap(): + # A top-level field literally named "_array" short-circuited the old + # depth walk (`if "_array" in node: return depth + 1`), so its real + # nesting was never counted. The compiler treats "_array" as a plain + # field name here (an array NODE is one CONTAINING an `_array` key), so + # the nesting under it is real -- and must still be capped. + decoy = {"_array": _nest(30)} + caps = SchemaCaps(max_depth=6) + with pytest.raises(SchemaError, match="max_depth"): + compile_schema(decoy, caps=caps) + + +def test_valid_array_within_limits_still_compiles(): + # Genuine array-column spec (node CONTAINING `_array`) with shallow + # columns must still compile -- the ceiling bounds total structural + # depth, it does not break real arrays. + spec = { + "rows": { + "description": "line items", + "_array": { + "sku": {"description": "SKU"}, + "qty": {"description": "Quantity", "format": "number"}, + }, + } + } + out = compile_schema(spec, caps=SchemaCaps(max_depth=6)) + assert isinstance(out, CompiledSchema) + assert out.array_specs[0].path == "rows" + assert [s.path for s in out.array_specs[0].item_specs] == ["sku", "qty"] + + +def test_pathologically_deep_raises_fast_no_recursion_error(): + # ~5000 levels: 256 KiB of JSON nests far past Python's ~1000 recursion + # limit, so the OLD code raised an uncaught RecursionError (500). Must be + # a clean, fast SchemaError instead. + deep, caps = _nest(5000), SchemaCaps(max_depth=6) + with pytest.raises(SchemaError, match="max_depth"): + compile_schema(deep, caps=caps) + + +def test_pathologically_deep_under_decoy_array_raises_fast(): + # Same DoS but hidden under a decoy `_array` key so the compile.py depth + # pre-check short-circuits -- the guard inside the real recursive walk + # (`kv_schema._walk`) must still catch it as SchemaError, not + # RecursionError. + decoy, caps = {"_array": _nest(5000)}, SchemaCaps(max_depth=6) + with pytest.raises(SchemaError, match="max_depth"): + compile_schema(decoy, caps=caps) + + +# --------------------------------------------------------------------------- +# Author-supplied regex is refused at SUBMIT, not discovered at match time. +# +# Two defects, both found in review: +# 1. The pattern was never compiled here, so an invalid one was accepted and +# only hit per-value in the engine's QA pass -- where `_check_one` swallows +# `re.error` and returns True, silently passing validation the author +# thought they had configured. +# 2. `validate_format` runs the pattern with no time budget, so catastrophic +# backtracking is a DoS. The length cap is NOT a mitigation: `^(a+)+$` is 7 +# characters and takes ~1.9s against 26 `a`s, ~4x per character added +# (measured on this code), so a 40-char value runs for hours -- and +# AGENT_KV_CONCURRENT_LIMIT=5 lets one org pin five shared worker slots +# from a single submit. +# +# The detector is a conservative heuristic, not a proof: it rejects the shape +# behind the realistic cases (a quantifier inside a quantified group). The +# complete fix is a linear-time engine (RE2), which cannot live here because +# this package deliberately has zero dependencies -- UN-4225. +# --------------------------------------------------------------------------- + + +def _schema(pattern: str) -> dict: + return {"amount": {"description": "x", "format": f"regex:{pattern}"}} + + +@pytest.mark.parametrize( + "pattern", + [ + "^(a+)+$", # the classic + "^(a*)*$", + "(?:x+)+", # non-capturing group is not an escape hatch + "^(a+){2,}$", # counted repetition of a quantified group + ], +) +def test_nested_quantifier_is_refused_at_compile(pattern): + spec = _schema(pattern) + with pytest.raises(SchemaError, match="nested quantifier"): + compile_schema(spec) + + +def test_invalid_regex_is_refused_at_compile_not_swallowed_at_match_time(): + spec = _schema("[") + with pytest.raises(SchemaError, match="invalid regex"): + compile_schema(spec) + + +@pytest.mark.parametrize( + "pattern", + [ + r"^\d{3}-\d{4}$", + r"^[A-Z]{2}\d+$", + r"^INV-\d+$", + r"^\$?[\d,]+\.\d{2}$", + ], +) +def test_ordinary_patterns_still_compile(pattern): + """The other half of the heuristic's contract. A detector that rejected + real-world patterns would just push authors off the regex format entirely. + """ + compiled = compile_schema(_schema(pattern)) + assert compiled.key_specs[0].regex_pattern == pattern + + +# The overlapping-alternation family, found in review AFTER the +# nested-quantifier check shipped: `^(a|aa)+$` passes that check and still +# backtracks catastrophically, because at each position the engine can consume +# one `a` or two and must try both on failure. +@pytest.mark.parametrize("pattern", ["^(a|aa)+$", "^(a|ab)+$", "(?:x|xx)+"]) +def test_overlapping_alternation_is_refused(pattern): + spec = _schema(pattern) + with pytest.raises(SchemaError, match="alternatives overlap"): + compile_schema(spec) + + +@pytest.mark.parametrize( + "pattern", + [ + "^(foo|bar)+$", # distinct first chars -> no position admits two parses + r"^(\d|[A-Z])+$", # metacharacter branches are not analysed + "^INV-(A|B)+$", + ], +) +def test_non_overlapping_alternation_still_compiles(pattern): + """The detector compares only LITERAL branches, by the prefix relation. + Refusing every quantified alternation would reject a lot of safe, ordinary + patterns -- `(?:a|b)+` is linear. + """ + compile_schema(_schema(pattern)) diff --git a/unstract/agent-kv-schema/tests/test_constraints.py b/unstract/agent-kv-schema/tests/test_constraints.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/unstract/agent-kv-schema/tests/test_constraints_tolerance.py b/unstract/agent-kv-schema/tests/test_constraints_tolerance.py new file mode 100644 index 0000000000..6bfdbf4d77 --- /dev/null +++ b/unstract/agent-kv-schema/tests/test_constraints_tolerance.py @@ -0,0 +1,131 @@ +"""Equality in a constraint is tolerant, not exact. + +Operands are normalized currency/number values that arrive as floats, so +binary floating point made exact `operator.eq` wrong for the one thing +constraints exist to express. The feature's own headline example reported a +false violation on a CORRECT invoice -- which is worse than no check, because +it trains reviewers to ignore the output. +""" + +import pytest + +from unstract.agent_kv_schema import evaluate_constraints + +# Three identical line items. 8230.4 * 3 is 24691.199999999997 in binary +# floating point, so `sum(...) == 24691.2` was False and the constraint was +# reported violated. +_ROWS = {"line_items": [{"line_total": "8230.40"} for _ in range(3)]} + + +def test_the_headline_example_is_not_a_false_violation(): + violations = evaluate_constraints( + ["grand_total == sum('line_items.line_total')"], + {"grand_total": "24691.20"}, + _ROWS, + ) + assert violations == [] + + +def test_a_real_mismatch_is_still_reported(): + """The tolerance must not swallow genuine errors -- an invoice off by a + cent is exactly what this check is for. + """ + violations = evaluate_constraints( + ["grand_total == sum('line_items.line_total')"], + {"grand_total": "24691.21"}, + _ROWS, + ) + assert violations == ["grand_total == sum('line_items.line_total')"] + + +def test_not_equal_is_the_negation_of_the_tolerant_equality(): + """`!=` must agree with `==`, or a schema author gets both reported.""" + expr = "grand_total != sum('line_items.line_total')" + assert evaluate_constraints([expr], {"grand_total": "24691.20"}, _ROWS) == [expr] + assert evaluate_constraints([expr], {"grand_total": "24691.21"}, _ROWS) == [] + + +def test_comparison_against_exact_zero_still_works(): + """Relative tolerance alone is useless at zero -- every non-zero value is + infinitely far from 0 in relative terms -- which is why abs_tol is also set. + """ + assert evaluate_constraints(["balance == 0"], {"balance": "0.00"}, {}) == [] + assert evaluate_constraints(["balance == 0"], {"balance": "1.00"}, {}) == [ + "balance == 0" + ] + + +def test_ordering_comparisons_stay_exact(): + """Left deliberately exact: a tolerant `<` would make `a < b` and `a == b` + both true at the boundary, and "a total that is too large" is not a + rounding artefact. + """ + assert evaluate_constraints(["total > 100"], {"total": "100.00"}, {}) == [ + "total > 100" + ] + assert evaluate_constraints(["total >= 100"], {"total": "100.00"}, {}) == [] + + +# The tolerance must sit BELOW a cent at every realistic magnitude, not just at +# invoice scale. `rel_tol=1e-9` (the first attempt) is 0.1 at a $100,000,000 +# total, so it silently absorbed a one-cent reconciliation error -- the exact +# failure the check exists to catch. The original cent-mismatch test only +# covered a ~$25,000 total and so could not see it. + + +def test_a_one_cent_error_is_caught_at_one_hundred_million(): + rows = {"line_items": [{"line_total": "50000000.00"} for _ in range(2)]} + expr = "grand_total == sum('line_items.line_total')" + assert evaluate_constraints([expr], {"grand_total": "100000000.01"}, rows) == [expr] + + +def test_a_correct_one_hundred_million_total_is_not_a_false_violation(): + """The other side: tightening must not start reporting correct documents.""" + rows = {"line_items": [{"line_total": "50000000.00"} for _ in range(2)]} + assert ( + evaluate_constraints( + ["grand_total == sum('line_items.line_total')"], + {"grand_total": "100000000.00"}, + rows, + ) + == [] + ) + + +def test_float_noise_is_still_absorbed_over_many_rows(): + """The lower bound on the tolerance. 1,000 rows of 8230.40 do not sum to + exactly 8230400.0 in binary floating point, and that must not be a + violation. + """ + rows = {"line_items": [{"line_total": "8230.40"} for _ in range(1000)]} + assert ( + evaluate_constraints( + ["grand_total == sum('line_items.line_total')"], + {"grand_total": "8230400.00"}, + rows, + ) + == [] + ) + + +# Long arrays. The aggregate uses `math.fsum`, so accumulation error does not +# grow with row count and a correct total stays correct at any realistic array +# length -- while a real one-cent error is still reported at the same length. +@pytest.mark.parametrize("rows", [1_000, 10_000, 100_000]) +def test_a_correct_total_is_not_a_violation_however_many_rows(rows): + items = {"line_items": [{"line_total": "8230.40"}] * rows} + total = f"{8230.40 * rows:.2f}" + assert ( + evaluate_constraints( + ["grand_total == sum('line_items.line_total')"], + {"grand_total": total}, + items, + ) + == [] + ) + + +def test_a_one_cent_error_is_still_caught_at_one_hundred_thousand_rows(): + items = {"line_items": [{"line_total": "8230.40"}] * 100_000} + expr = "grand_total == sum('line_items.line_total')" + assert evaluate_constraints([expr], {"grand_total": "823040000.01"}, items) == [expr] diff --git a/unstract/agent-kv-schema/uv.lock b/unstract/agent-kv-schema/uv.lock new file mode 100644 index 0000000000..cef30e8cf2 --- /dev/null +++ b/unstract/agent-kv-schema/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "unstract-agent-kv-schema" +version = "0.1.0" +source = { editable = "." } diff --git a/unstract/filesystem/src/unstract/filesystem/file_storage_config.py b/unstract/filesystem/src/unstract/filesystem/file_storage_config.py index 870ed30a02..06ba625154 100644 --- a/unstract/filesystem/src/unstract/filesystem/file_storage_config.py +++ b/unstract/filesystem/src/unstract/filesystem/file_storage_config.py @@ -29,10 +29,12 @@ def get_provider(var_name: str, default: str = "minio") -> FileStorageProvider: FileStorageType.WORKFLOW_EXECUTION: StorageType.SHARED_TEMPORARY, FileStorageType.API_EXECUTION: StorageType.SHARED_TEMPORARY, FileStorageType.HITL_FILES: StorageType.SHARED_TEMPORARY, # Use shared temporary for HITL files + FileStorageType.AGENT_KV: StorageType.SHARED_TEMPORARY, } FILE_STORAGE_CREDENTIALS_TO_ENV_NAME_MAPPING = { FileStorageType.WORKFLOW_EXECUTION: "WORKFLOW_EXECUTION_FILE_STORAGE_CREDENTIALS", FileStorageType.API_EXECUTION: "API_FILE_STORAGE_CREDENTIALS", FileStorageType.HITL_FILES: "HITL_FILES_FILE_STORAGE_CREDENTIALS", + FileStorageType.AGENT_KV: "AGENT_KV_FILE_STORAGE_CREDENTIALS", } diff --git a/unstract/filesystem/src/unstract/filesystem/file_storage_types.py b/unstract/filesystem/src/unstract/filesystem/file_storage_types.py index 2b6956fe69..b27241071c 100644 --- a/unstract/filesystem/src/unstract/filesystem/file_storage_types.py +++ b/unstract/filesystem/src/unstract/filesystem/file_storage_types.py @@ -5,3 +5,4 @@ class FileStorageType(Enum): WORKFLOW_EXECUTION = "WORKFLOW_EXECUTION" API_EXECUTION = "API_EXECUTION" HITL_FILES = "HITL_FILES" + AGENT_KV = "AGENT_KV" diff --git a/unstract/filesystem/tests/__init__.py b/unstract/filesystem/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/unstract/filesystem/tests/test_agent_kv_storage_type.py b/unstract/filesystem/tests/test_agent_kv_storage_type.py new file mode 100644 index 0000000000..63f44ae4da --- /dev/null +++ b/unstract/filesystem/tests/test_agent_kv_storage_type.py @@ -0,0 +1,15 @@ +from unstract.filesystem.file_storage_config import ( + FILE_STORAGE_CREDENTIALS_TO_ENV_NAME_MAPPING, + STORAGE_MAPPING, +) +from unstract.filesystem.file_storage_types import FileStorageType + + +def test_agent_kv_type_exists_and_is_mapped(): + t = FileStorageType.AGENT_KV + assert t.value == "AGENT_KV" + assert t in STORAGE_MAPPING + assert ( + FILE_STORAGE_CREDENTIALS_TO_ENV_NAME_MAPPING[t] + == "AGENT_KV_FILE_STORAGE_CREDENTIALS" + ) diff --git a/uv.lock b/uv.lock index d7dd25246b..b69549e83e 100644 --- a/uv.lock +++ b/uv.lock @@ -3708,6 +3708,11 @@ test-rig = [ ] workers = [{ name = "unstract-workers", editable = "workers" }] +[[package]] +name = "unstract-agent-kv-schema" +version = "0.1.0" +source = { editable = "unstract/agent-kv-schema" } + [[package]] name = "unstract-connectors" version = "0.0.3" @@ -3955,6 +3960,7 @@ dependencies = [ { name = "python-socketio" }, { name = "redis" }, { name = "requests" }, + { name = "unstract-agent-kv-schema" }, { name = "unstract-connectors" }, { name = "unstract-core" }, { name = "unstract-filesystem" }, @@ -3980,6 +3986,7 @@ requires-dist = [ { name = "python-socketio", specifier = ">=5.9.0" }, { name = "redis", specifier = ">=4.5.0,<6.0.0" }, { name = "requests", specifier = ">=2.33.0,<3.0.0" }, + { name = "unstract-agent-kv-schema", editable = "unstract/agent-kv-schema" }, { name = "unstract-connectors", editable = "unstract/connectors" }, { name = "unstract-core", editable = "unstract/core" }, { name = "unstract-filesystem", editable = "unstract/filesystem" }, diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py new file mode 100644 index 0000000000..b74cb1ed31 --- /dev/null +++ b/workers/ide_callback/agent_kv_tasks.py @@ -0,0 +1,181 @@ +"""Agent-KV terminal callbacks (spec §5.3). Thin: parse, finalize, webhook. + +These are the Celery ``link``/``link_error`` callbacks that +``agent_kv.dispatch.dispatch_job`` (backend, Task 8) attaches to the +executor dispatch: ``agent_kv_complete`` on success, ``agent_kv_error`` on +an unhandled executor exception. Both run on the dedicated +``agent_kv_callback`` queue (see +``shared/infrastructure/config/registry.py``) served by the IDE_CALLBACK +worker, and both terminalize the job via +``InternalAPIClient.agent_kv_finalize`` (Task 11's internal endpoint) +before firing the completion webhook (Task 13). +""" + +import logging +import os +from typing import Any + +from queue_backend import worker_task +from shared.utils.webhook_notify import send_webhook + +logger = logging.getLogger(__name__) + +_UNKNOWN = "Executor failed without an error message" + + +def _get_api_client(): + """Lazily build an InternalAPIClient. + + Lazy import + plain instantiation (no shared config/session): mirrors + ``queue_backend/pg_queue/consumer.py`` and ``.../reaper.py``'s + ``_get_api_client`` helpers, which use this exact pattern to keep the + module import-cycle-free at load time. Unlike ``ide_callback.tasks``'s + ``_get_api_client`` (which returns a ``PromptStudioAPIClient`` scoped to + a handful of prompt-studio endpoints), this callback talks to a general + internal endpoint, so it goes through the general-purpose + ``InternalAPIClient`` facade that owns ``agent_kv_finalize``. + """ + from shared.api import InternalAPIClient + + return InternalAPIClient() + + +def _resolve_error(failed_task_id: str, explicit: str | None = None) -> str: + """Resolve the real error text for the ``agent_kv_error`` link_error callback. + + Mirrors ``ide_callback.tasks._get_task_error``'s precedence exactly: + prefer ``explicit`` when the caller already has it. The PG-queue + transport's self-chained error path (``queue_backend/pg_queue/consumer.py``'s + ``_chain_continuation``) hands the real exception through + ``callback_kwargs["error"]`` -- on that path the executor ran eagerly and + never wrote a Celery result backend entry under ``failed_task_id``, so + the ``AsyncResult`` lookup below would come back empty. The Celery + ``link_error`` path (``agent_kv.dispatch.dispatch_job``) passes no + explicit error and relies on the result backend, then ``_UNKNOWN``. + + Falsy-aware (``explicit or ...``), matching ``_get_task_error``'s own + ``if explicit is not None`` intent in practice: an empty-string + ``explicit`` (e.g. an executor that raised with no message) must not be + persisted verbatim as a blank error -- it falls through to the result + backend lookup, then ``_UNKNOWN``, same as an absent explicit error. + + Kept as a sibling implementation rather than importing + ``ide_callback.tasks._get_task_error`` directly: ``ide_callback/tasks.py`` + already imports *this* module at its own bottom (so both of + ``workers/worker.py``'s task-loading mechanisms register + ``agent_kv_complete``/``agent_kv_error`` -- see that file's comment). + Importing back from ``tasks.py`` here would still resolve (Python + tolerates the resulting redundant re-import under the two different + ``sys.modules`` names that mechanism ends up using), but it adds a + two-way coupling between the modules for ~10 lines of small, stable + logic that isn't worth the added complexity to reason about. + """ + if explicit: + return explicit + try: + from celery import current_app as app + from celery.result import AsyncResult + + res = AsyncResult(failed_task_id, app=app) + if res.result: + return str(res.result) + except Exception: + pass + return _UNKNOWN + + +@worker_task(name="agent_kv_complete") +def agent_kv_complete( + result_dict: dict[str, Any], + callback_kwargs: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Celery link callback after the agent-kv executor task returns. + + ``result_dict["success"]`` reflects the *executor's* own outcome: it can + still be ``False`` here even though this is the success ``link`` (the + executor caught its error and returned a structured failure instead of + raising, which would have routed to ``agent_kv_error`` instead). Either + way the job is finalized via the internal API, then the webhook fires + if the response says to. + """ + cb = callback_kwargs or {} + job_id = cb.get("job_id", "") + org_id = cb.get("org_id", "") + api = _get_api_client() + + try: + if not result_dict.get("success", False): + error = result_dict.get("error") or _UNKNOWN + logger.error( + "agent_kv executor reported failure: job_id=%s error=%s", job_id, error + ) + out = api.agent_kv_finalize(job_id, org_id, success=False, error=error) + else: + data = result_dict.get("data") or {} + out = api.agent_kv_finalize( + job_id, + org_id, + success=True, + result=data.get("output") or {}, + usage_summary=data.get("usage_summary"), + ) + + _maybe_webhook(out, job_id) + return {"job_id": job_id, "finalized": out.get("finalized", False)} + + except Exception: + logger.exception( + "agent_kv_complete callback failed: job_id=%s org_id=%s", job_id, org_id + ) + raise + + +@worker_task(name="agent_kv_error") +def agent_kv_error( + failed_task_id: str, + callback_kwargs: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """Celery link_error callback when the agent-kv executor task raises.""" + cb = callback_kwargs or {} + job_id = cb.get("job_id", "") + org_id = cb.get("org_id", "") + api = _get_api_client() + + try: + error = _resolve_error(failed_task_id, explicit=cb.get("error")) + logger.error("agent_kv executor task failed: job_id=%s error=%s", job_id, error) + out = api.agent_kv_finalize(job_id, org_id, success=False, error=error) + + _maybe_webhook(out, job_id) + return {"job_id": job_id, "finalized": out.get("finalized", False)} + + except Exception: + logger.exception( + "agent_kv_error callback failed: job_id=%s org_id=%s", job_id, org_id + ) + return None + + +def _maybe_webhook(finalize_response: dict[str, Any], job_id: str) -> None: + """Fire the completion webhook, but only for a fresh finalize. + + ``finalized`` is ``False`` for a duplicate/late finalize call (the job + was already terminal) as well as for an unknown job -- either way the + webhook already fired (or never should), so firing again here would + double-notify the caller. + """ + if not finalize_response.get("finalized"): + return + url = finalize_response.get("webhook_url") or "" + if not url: + return + # Test/dev stacks only (e2e lane): waive the SSRF guards so a receiver on + # the compose host is reachable. Unset/false in production. + allow_insecure = os.environ.get( + "AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE", "" + ).lower() in ("1", "true", "yes") + send_webhook( + url, + {"job_id": job_id, "status": finalize_response.get("status", "")}, + allow_insecure=allow_insecure, + ) diff --git a/workers/ide_callback/tasks.py b/workers/ide_callback/tasks.py index 1a298dffe3..968879bac6 100644 --- a/workers/ide_callback/tasks.py +++ b/workers/ide_callback/tasks.py @@ -693,3 +693,19 @@ def extraction_error( source, file_id, ) + + +# ------------------------------------------------------------------ +# Agent-KV terminal callbacks (spec §5.3) +# +# ``workers/worker.py``'s ``load_worker_tasks()`` registers this worker +# type's tasks by loading THIS file directly (by path, under the bare +# module name ``"tasks"``) rather than importing the ``ide_callback`` +# package -- so ``agent_kv_complete``/``agent_kv_error`` only bind to the +# Celery app if something imports them as a side effect of importing this +# module. Import at the bottom (not the top) to avoid a circular import: +# ``agent_kv_tasks`` doesn't currently import back from here, but keeping +# task-module imports last matches this file's own load-bearing position +# in the worker boot sequence. +# ------------------------------------------------------------------ +from ide_callback import agent_kv_tasks # noqa: E402, F401 diff --git a/workers/ide_callback/tests/__init__.py b/workers/ide_callback/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/workers/ide_callback/tests/test_agent_kv_callbacks.py b/workers/ide_callback/tests/test_agent_kv_callbacks.py new file mode 100644 index 0000000000..4b1e2f1ef1 --- /dev/null +++ b/workers/ide_callback/tests/test_agent_kv_callbacks.py @@ -0,0 +1,401 @@ +"""Unit tests for Agent-KV terminal callback tasks (spec §5.3). + +Mirrors ``tests/test_ide_callback.py``'s style: task functions are called +directly (bypassing Celery task machinery) since we're testing callback +logic, not Celery routing. ``_get_api_client`` and ``send_webhook`` are +mocked at the ``ide_callback.agent_kv_tasks`` import site. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +_PATCH_GET_CLIENT = "ide_callback.agent_kv_tasks._get_api_client" +_PATCH_SEND_WEBHOOK = "ide_callback.agent_kv_tasks.send_webhook" +_PATCH_ASYNC_RESULT = "celery.result.AsyncResult" + +_UNKNOWN = "Executor failed without an error message" + + +@pytest.fixture +def mock_api(): + """A mocked InternalAPIClient whose agent_kv_finalize succeeds with a webhook.""" + api = MagicMock() + api.agent_kv_finalize.return_value = { + "finalized": True, + "webhook_url": "https://example.com/hook", + "status": "completed", + } + return api + + +@pytest.fixture +def cb_kwargs(): + """Standard callback_kwargs, matching agent_kv.dispatch.dispatch_job's shape.""" + return {"job_id": "job-1", "org_id": "org-1"} + + +# --------------------------------------------------------------------------- +# agent_kv_complete +# --------------------------------------------------------------------------- + + +class TestAgentKvComplete: + def _call(self, result_dict, callback_kwargs=None): + from ide_callback.agent_kv_tasks import agent_kv_complete + + return agent_kv_complete(result_dict, callback_kwargs) + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_success_path_finalizes_true_with_engine_result( + self, mock_get_client, mock_send_webhook, mock_api, cb_kwargs + ): + mock_get_client.return_value = mock_api + result_dict = { + "success": True, + "data": { + "output": {"field_1": "value_1"}, + "usage_summary": {"total_tokens": 42}, + }, + "error": None, + } + + result = self._call(result_dict, cb_kwargs) + + mock_api.agent_kv_finalize.assert_called_once_with( + "job-1", + "org-1", + success=True, + result={"field_1": "value_1"}, + usage_summary={"total_tokens": 42}, + ) + assert result == {"job_id": "job-1", "finalized": True} + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_missing_output_and_usage_summary_default_to_empty( + self, mock_get_client, mock_send_webhook, mock_api, cb_kwargs + ): + mock_get_client.return_value = mock_api + result_dict = {"success": True, "data": {}, "error": None} + + self._call(result_dict, cb_kwargs) + + mock_api.agent_kv_finalize.assert_called_once_with( + "job-1", "org-1", success=True, result={}, usage_summary=None + ) + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_executor_reported_failure_finalizes_false_with_error( + self, mock_get_client, mock_send_webhook, mock_api, cb_kwargs + ): + mock_get_client.return_value = mock_api + result_dict = {"success": False, "data": None, "error": "boom"} + + self._call(result_dict, cb_kwargs) + + mock_api.agent_kv_finalize.assert_called_once_with( + "job-1", "org-1", success=False, error="boom" + ) + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_executor_failure_with_no_error_message_uses_fallback( + self, mock_get_client, mock_send_webhook, mock_api, cb_kwargs + ): + mock_get_client.return_value = mock_api + result_dict = {"success": False, "data": None, "error": None} + + self._call(result_dict, cb_kwargs) + + mock_api.agent_kv_finalize.assert_called_once_with( + "job-1", "org-1", success=False, error=_UNKNOWN + ) + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_none_callback_kwargs_uses_empty_defaults( + self, mock_get_client, mock_send_webhook, mock_api + ): + mock_get_client.return_value = mock_api + result_dict = {"success": True, "data": {"output": {}}, "error": None} + + result = self._call(result_dict, None) + + mock_api.agent_kv_finalize.assert_called_once_with( + "", "", success=True, result={}, usage_summary=None + ) + assert result["job_id"] == "" + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_returns_finalized_false_when_finalize_says_so( + self, mock_get_client, mock_send_webhook, cb_kwargs + ): + """A duplicate/late callback: the backend reports finalized=False.""" + api = MagicMock() + api.agent_kv_finalize.return_value = { + "finalized": False, + "webhook_url": "", + "status": "completed", + } + mock_get_client.return_value = api + result_dict = {"success": True, "data": {"output": {}}, "error": None} + + result = self._call(result_dict, cb_kwargs) + + assert result == {"job_id": "job-1", "finalized": False} + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_finalize_raising_is_logged_and_reraised( + self, mock_get_client, mock_send_webhook, cb_kwargs, caplog + ): + """agent_kv_complete mirrors ide_index_complete: log, then re-raise.""" + import logging + + api = MagicMock() + api.agent_kv_finalize.side_effect = RuntimeError("backend unreachable") + mock_get_client.return_value = api + result_dict = {"success": True, "data": {"output": {}}, "error": None} + + with caplog.at_level(logging.ERROR, logger="ide_callback.agent_kv_tasks"): + with pytest.raises(RuntimeError, match="backend unreachable"): + self._call(result_dict, cb_kwargs) + + assert "agent_kv_complete callback failed" in caplog.text + mock_send_webhook.assert_not_called() + + +# --------------------------------------------------------------------------- +# agent_kv_error +# --------------------------------------------------------------------------- + + +class TestAgentKvError: + def _call(self, failed_task_id, callback_kwargs=None): + from ide_callback.agent_kv_tasks import agent_kv_error + + return agent_kv_error(failed_task_id, callback_kwargs) + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_error_link_finalizes_false_with_result_backend_error( + self, mock_get_client, mock_send_webhook, mock_api, cb_kwargs + ): + mock_get_client.return_value = mock_api + mock_async_result = MagicMock() + mock_async_result.result = RuntimeError("executor crashed") + + with patch(_PATCH_ASYNC_RESULT, return_value=mock_async_result): + result = self._call("failed-task-1", cb_kwargs) + + mock_api.agent_kv_finalize.assert_called_once_with( + "job-1", "org-1", success=False, error="executor crashed" + ) + assert result == {"job_id": "job-1", "finalized": True} + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_error_link_falls_back_when_no_result_available( + self, mock_get_client, mock_send_webhook, mock_api, cb_kwargs + ): + mock_get_client.return_value = mock_api + mock_async_result = MagicMock() + mock_async_result.result = None + + with patch(_PATCH_ASYNC_RESULT, return_value=mock_async_result): + self._call("failed-task-2", cb_kwargs) + + mock_api.agent_kv_finalize.assert_called_once_with( + "job-1", "org-1", success=False, error=_UNKNOWN + ) + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_error_link_falls_back_when_lookup_raises( + self, mock_get_client, mock_send_webhook, mock_api, cb_kwargs + ): + mock_get_client.return_value = mock_api + + with patch(_PATCH_ASYNC_RESULT, side_effect=RuntimeError("backend down")): + self._call("failed-task-3", cb_kwargs) + + mock_api.agent_kv_finalize.assert_called_once_with( + "job-1", "org-1", success=False, error=_UNKNOWN + ) + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_none_callback_kwargs_uses_empty_defaults( + self, mock_get_client, mock_send_webhook, mock_api + ): + mock_get_client.return_value = mock_api + + with patch(_PATCH_ASYNC_RESULT, return_value=MagicMock(result=None)): + result = self._call("failed-task-4", None) + + mock_api.agent_kv_finalize.assert_called_once_with( + "", "", success=False, error=_UNKNOWN + ) + assert result["job_id"] == "" + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_pg_transport_explicit_error_preferred_over_async_result( + self, mock_get_client, mock_send_webhook, mock_api + ): + """PG-queue self-chained path: callback_kwargs carries the real error. + + ``queue_backend/pg_queue/consumer.py``'s ``_chain_continuation`` injects + the executor's real error into ``callback_kwargs["error"]`` because the + PG path runs the executor eagerly and never writes a Celery result + backend entry under ``failed_task_id`` -- so this must be preferred + over (and must skip) the ``AsyncResult`` lookup entirely. + """ + mock_get_client.return_value = mock_api + pg_cb_kwargs = {"job_id": "job-1", "org_id": "org-1", "error": "real cause"} + + with patch(_PATCH_ASYNC_RESULT) as mock_async_result_cls: + result = self._call("failed-task-pg-1", pg_cb_kwargs) + + mock_async_result_cls.assert_not_called() + mock_api.agent_kv_finalize.assert_called_once_with( + "job-1", "org-1", success=False, error="real cause" + ) + assert result == {"job_id": "job-1", "finalized": True} + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_pg_transport_empty_explicit_error_falls_back_to_async_result( + self, mock_get_client, mock_send_webhook, mock_api + ): + """An empty-string explicit error must not be persisted verbatim -- + it's falsy, so it falls through to the result-backend lookup (then + ``_UNKNOWN``), same as an absent explicit error. + """ + mock_get_client.return_value = mock_api + pg_cb_kwargs = {"job_id": "job-1", "org_id": "org-1", "error": ""} + mock_async_result = MagicMock() + mock_async_result.result = RuntimeError("real backend cause") + + with patch(_PATCH_ASYNC_RESULT, return_value=mock_async_result): + self._call("failed-task-empty-error", pg_cb_kwargs) + + mock_api.agent_kv_finalize.assert_called_once_with( + "job-1", "org-1", success=False, error="real backend cause" + ) + + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_finalize_raising_is_swallowed_and_logged( + self, mock_get_client, mock_send_webhook, cb_kwargs, caplog + ): + """agent_kv_error mirrors ide_index_error: swallow, log, don't raise.""" + import logging + + api = MagicMock() + api.agent_kv_finalize.side_effect = RuntimeError("backend unreachable") + mock_get_client.return_value = api + + with ( + patch(_PATCH_ASYNC_RESULT, return_value=MagicMock(result=None)), + caplog.at_level(logging.ERROR, logger="ide_callback.agent_kv_tasks"), + ): + result = self._call("failed-task-6", cb_kwargs) + + assert result is None + assert "agent_kv_error callback failed" in caplog.text + mock_send_webhook.assert_not_called() + + +# --------------------------------------------------------------------------- +# Webhook firing rules (shared by both callbacks) +# --------------------------------------------------------------------------- + + +class TestWebhookFiring: + """Webhook fires iff finalize returns finalized=True AND a non-empty webhook_url.""" + + def _call_complete(self, callback_kwargs, api): + from ide_callback.agent_kv_tasks import agent_kv_complete + + with patch(_PATCH_GET_CLIENT, return_value=api): + result_dict = {"success": True, "data": {"output": {"x": 1}}, "error": None} + return agent_kv_complete(result_dict, callback_kwargs) + + @patch(_PATCH_SEND_WEBHOOK) + def test_webhook_fires_when_finalized_true_and_url_present( + self, mock_send_webhook, cb_kwargs + ): + api = MagicMock() + api.agent_kv_finalize.return_value = { + "finalized": True, + "webhook_url": "https://example.com/hook", + "status": "completed", + } + + self._call_complete(cb_kwargs, api) + + mock_send_webhook.assert_called_once_with( + "https://example.com/hook", + {"job_id": "job-1", "status": "completed"}, + allow_insecure=False, + ) + + @patch(_PATCH_SEND_WEBHOOK) + def test_webhook_not_fired_on_duplicate_finalize_false( + self, mock_send_webhook, cb_kwargs + ): + """Duplicate/late finalize: finalized=False must not re-fire the webhook, + even though the job (from an earlier finalize) still carries a webhook_url. + """ + api = MagicMock() + api.agent_kv_finalize.return_value = { + "finalized": False, + "webhook_url": "https://example.com/hook", + "status": "completed", + } + + self._call_complete(cb_kwargs, api) + + mock_send_webhook.assert_not_called() + + @patch(_PATCH_SEND_WEBHOOK) + def test_webhook_not_fired_when_url_empty(self, mock_send_webhook, cb_kwargs): + """finalized=True but the job has no webhook configured: nothing to call.""" + api = MagicMock() + api.agent_kv_finalize.return_value = { + "finalized": True, + "webhook_url": "", + "status": "completed", + } + + self._call_complete(cb_kwargs, api) + + mock_send_webhook.assert_not_called() + + @patch(_PATCH_SEND_WEBHOOK) + def test_webhook_fires_from_error_link_too(self, mock_send_webhook, cb_kwargs): + from ide_callback.agent_kv_tasks import agent_kv_error + + api = MagicMock() + api.agent_kv_finalize.return_value = { + "finalized": True, + "webhook_url": "https://example.com/hook", + "status": "failed", + } + + with ( + patch(_PATCH_GET_CLIENT, return_value=api), + patch(_PATCH_ASYNC_RESULT, return_value=MagicMock(result=None)), + ): + agent_kv_error("failed-task-5", cb_kwargs) + + mock_send_webhook.assert_called_once_with( + "https://example.com/hook", + {"job_id": "job-1", "status": "failed"}, + allow_insecure=False, + ) diff --git a/workers/ide_callback/worker.py b/workers/ide_callback/worker.py index cb1bb4200c..36a2c25ca8 100644 --- a/workers/ide_callback/worker.py +++ b/workers/ide_callback/worker.py @@ -56,3 +56,16 @@ def check_ide_callback_health(): WorkerRegistry.register_health_check( WorkerType.IDE_CALLBACK, "ide_callback_health", check_ide_callback_health ) + +# Import task modules so Celery registers them on this app -- mirrors how +# other single-app workers (e.g. executor/worker.py) import their tasks +# module explicitly. NOTE: this module is not the actual Celery entrypoint +# in production today -- both docker-compose and run-worker(.sh|-docker.sh) +# always launch the unified `workers/worker.py` (`celery -A worker worker`) +# for this worker type, which loads `ide_callback/tasks.py` itself via +# `load_worker_tasks()` (see that file's bottom-of-file import of +# `agent_kv_tasks` for the mechanism that actually applies in production). +# These imports are kept here anyway so this module stays correct/self- +# registering if it's ever wired up as a real `-A` target. +import ide_callback.agent_kv_tasks # noqa: E402, F401 +import ide_callback.tasks # noqa: E402, F401 diff --git a/workers/pyproject.toml b/workers/pyproject.toml index da672e0661..6842772245 100644 --- a/workers/pyproject.toml +++ b/workers/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "unstract-tool-sandbox", "unstract-workflow-execution", "unstract-filesystem", + "unstract-agent-kv-schema", # Caching "redis>=4.5.0,<6.0.0", # Redis client for worker cache access # PG Queue — direct Postgres access for the bespoke SKIP LOCKED queue @@ -223,6 +224,7 @@ exclude_lines = [ [tool.uv.sources] unstract-filesystem = { path = "../unstract/filesystem", editable = true } +unstract-agent-kv-schema = { path = "../unstract/agent-kv-schema", editable = true } unstract-workflow-execution = { path = "../unstract/workflow-execution", editable = true } unstract-tool-sandbox = { path = "../unstract/tool-sandbox", editable = true } unstract-tool-registry = { path = "../unstract/tool-registry", editable = true } diff --git a/workers/run-worker-docker.sh b/workers/run-worker-docker.sh index 05d2b9398f..eeafc65dfc 100755 --- a/workers/run-worker-docker.sh +++ b/workers/run-worker-docker.sh @@ -62,7 +62,10 @@ declare -A WORKER_QUEUES=( ["log_consumer"]="celery_log_task_queue" ["scheduler"]="scheduler" ["${EXECUTOR_WORKER_TYPE}"]="celery_executor_legacy" - ["ide_callback"]="ide_callback" + # agent_kv_callback carries the Agent-KV terminal callbacks + # (agent_kv_complete/agent_kv_error, spec §5.3) dispatched by + # backend/agent_kv/dispatch.py; ide_callback owns both queues. + ["ide_callback"]="ide_callback,agent_kv_callback" ) # Worker health ports diff --git a/workers/run-worker.sh b/workers/run-worker.sh index 5bd41dfbeb..e538831c8d 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -87,7 +87,7 @@ declare -rA PG_CONSUMER_ROLES=( # succeeds, so nothing errors at the producer. ["$PG_ROLE_NOTIFICATION"]="notification;notifications,notifications_webhook,notifications_email,notifications_sms,notifications_priority" # Prompt Studio IDE callbacks (ide_index_*/ide_prompt_*/extraction_*). - ["$PG_ROLE_IDE_CALLBACK"]="ide_callback;ide_callback" + ["$PG_ROLE_IDE_CALLBACK"]="ide_callback;ide_callback,agent_kv_callback" ) declare -rA PG_QUEUE_MEMBERS=( ["$PG_QUEUE_CONSUMER_TYPE"]=1 @@ -168,7 +168,10 @@ declare -A WORKER_QUEUES=( ["notification"]="notifications,notifications_webhook,notifications_email,notifications_sms,notifications_priority" ["scheduler"]="scheduler" ["${EXECUTOR_WORKER_TYPE}"]="celery_executor_legacy" - ["${IDE_CALLBACK_WORKER_TYPE}"]="${IDE_CALLBACK_WORKER_TYPE}" + # agent_kv_callback carries the Agent-KV terminal callbacks + # (agent_kv_complete/agent_kv_error, spec §5.3) dispatched by + # backend/agent_kv/dispatch.py; ide_callback owns both queues. + ["${IDE_CALLBACK_WORKER_TYPE}"]="${IDE_CALLBACK_WORKER_TYPE},agent_kv_callback" # The PG queue (in pg_queue_message) this consumer polls — exported as # WORKER_PG_QUEUE_CONSUMER_QUEUE, not a Celery --queues value. ["$PG_QUEUE_CONSUMER_TYPE"]="notifications" diff --git a/workers/sample.env b/workers/sample.env index b6c1078571..bffe936a26 100644 --- a/workers/sample.env +++ b/workers/sample.env @@ -398,6 +398,18 @@ UNSTRACT_RUNNER_API_BACKOFF_FACTOR=3 # File Storage Credentials (MinIO) WORKFLOW_EXECUTION_FILE_STORAGE_CREDENTIALS='{"provider": "minio", "credentials": {"endpoint_url": "http://unstract-minio:9000", "key": "minio", "secret": "minio123"}}' API_FILE_STORAGE_CREDENTIALS='{"provider": "minio", "credentials": {"endpoint_url": "http://unstract-minio:9000", "key": "minio", "secret": "minio123"}}' +# Bucket-rooted root the backend stages Agent-KV inputs under +# ({AGENT_KV_STORAGE_DIR_PREFIX}/{org_id}/{job_id}/...); the executor plugin reads +# `input_ref` from it and keys its OCR cache under {prefix}/{org_id}/cache/. +# MUST be identical to backend/sample.env's value -- the first segment is the +# bucket (must exist; the MinIO dev bootstrap creates `unstract`). +AGENT_KV_STORAGE_DIR_PREFIX="unstract/agent_kv" +# Read directly by the cloud agentic_table executor plugin's blind-API +# operation (workers/plugins/agentic_table, +# not checked in here) to stage/fetch Agent-KV job input+result files -- same +# shape and bucket as its siblings above. Must match docker/sample.env's copy +# (the backend, which also reads this key, gets its value from there). +AGENT_KV_FILE_STORAGE_CREDENTIALS='{"provider": "minio", "credentials": {"endpoint_url": "http://unstract-minio:9000", "key": "minio", "secret": "minio123"}}' # Remote storage for Prompt Studio / IDE flows. Must match backend/sample.env. # Required by executor and ide-callback workers; missing/empty values raise FileStorageError in EnvHelper.get_storage(). @@ -527,6 +539,7 @@ GOOGLE_OAUTH2_SECRET= # UNSTRACT_RUNNER_HOST=http://localhost # WORKFLOW_EXECUTION_FILE_STORAGE_CREDENTIALS={"provider": "minio", "credentials": {"endpoint_url": "http://localhost:9000", "key": "minio", "secret": "minio123"}} # API_FILE_STORAGE_CREDENTIALS={"provider": "minio", "credentials": {"endpoint_url": "http://localhost:9000", "key": "minio", "secret": "minio123"}} +# AGENT_KV_FILE_STORAGE_CREDENTIALS={"provider": "minio", "credentials": {"endpoint_url": "http://localhost:9000", "key": "minio", "secret": "minio123"}} # Flipt Service FLIPT_SERVICE_AVAILABLE=False @@ -549,3 +562,8 @@ PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python # BACKOFF = base seconds, doubling each attempt, with each sleep capped at 5.0s. # WORKER_PG_QUEUE_CONNECT_RETRIES=3 # WORKER_PG_QUEUE_CONNECT_BACKOFF=0.5 + +# Test/dev ONLY: waive the completion-webhook SSRF guards (https scheme + +# public host) on the ide-callback worker so the e2e lane can deliver to a +# receiver on the compose host. NEVER set in production. +# AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=false diff --git a/workers/scheduler/agent_kv_tasks.py b/workers/scheduler/agent_kv_tasks.py new file mode 100644 index 0000000000..0f266f7e46 --- /dev/null +++ b/workers/scheduler/agent_kv_tasks.py @@ -0,0 +1,71 @@ +"""Thin scheduler-side tasks for the Agent-KV maintenance periodics (spec §5.4). + +Mirrors ``dashboard_metrics_tasks.py``'s shape and role in this worker (UN-3796): +these do **no** work themselves, each just calls a backend internal endpoint that +does the real work, because the workers image has no Django and the ORM-heavy +job-row work (terminalizing stuck/never-dispatched jobs, deleting staged files) +can only run there. + +Unlike the dashboard-metrics proxies, the calls here go through the shared +``InternalAPIClient`` facade (``shared.api.InternalAPIClient``) rather than a +bespoke ``httpx`` client -- ``agent_kv_finalize`` already lives on that facade +(Task 11) for the Agent-KV terminal callbacks +(``ide_callback/agent_kv_tasks.py::_get_api_client``), and the sweep/TTL-cleanup +calls added here (``agent_kv_sweep``/``agent_kv_ttl_cleanup``, +``workers/shared/api/internal_client.py``) are its siblings on the same client, +not a second HTTP stack for the same feature. + +Two internal endpoints, both platform-wide (no ``org_id``, spec §5.4): + +- ``POST /internal/v1/agent-kv/sweep/`` -- terminalizes never-dispatched + ``PENDING`` jobs and stuck ``DISPATCHED``/``RUNNING`` jobs. +- ``POST /internal/v1/agent-kv/ttl-cleanup/`` -- deletes staged input/result + files for jobs past ``expires_at`` and blanks their refs. + +Both handlers are idempotent and batch-capped server-side +(``backend/agent_kv/internal_views.py``), so redelivery/backlog from a missed +tick is safe without extra guards here -- exactly like the dashboard-metrics +proxies' own redelivery story. + +**Not registered under a dotted Beat-mirroring name for a Beat row that +exists** (contrast ``dashboard_metrics_tasks.py``, whose names are pinned to +verbatim Beat rows it mirrors): there is no pre-existing Beat/PG-scheduler +entry for these two tasks to match, since Agent-KV shipped with the internal +endpoints but no periodic registration (spec §5.4, this feature's own +deploy-checklist gap). The ``agent_kv.sweep``/``agent_kv.ttl_cleanup`` wire +names below are this task pair's own new contract -- an operator registers a +``PgPeriodicTask`` row against them (see `docs/agent-kv-api.md`'s deploy +checklist for the exact shape), the same PG-scheduler mechanism the +dashboard-metrics periodics use. +""" + +import logging +from typing import Any + +from queue_backend import worker_task + +logger = logging.getLogger(__name__) + + +def _get_api_client(): + """Lazily build an InternalAPIClient. + + Mirrors ``ide_callback.agent_kv_tasks._get_api_client``'s lazy import + + plain instantiation (no shared config/session) for the same reason: keeps + this module import-cycle-free at load time. + """ + from shared.api import InternalAPIClient + + return InternalAPIClient() + + +@worker_task(name="agent_kv.sweep") +def agent_kv_sweep() -> dict[str, Any]: + """Terminalize never-dispatched PENDING and stuck Agent-KV jobs (spec §5.4).""" + return _get_api_client().agent_kv_sweep() + + +@worker_task(name="agent_kv.ttl_cleanup") +def agent_kv_ttl_cleanup() -> dict[str, Any]: + """Delete staged Agent-KV input/result files past their TTL (spec §5.4).""" + return _get_api_client().agent_kv_ttl_cleanup() diff --git a/workers/scheduler/tasks.py b/workers/scheduler/tasks.py index d79f7bf758..56bcebcfb5 100644 --- a/workers/scheduler/tasks.py +++ b/workers/scheduler/tasks.py @@ -23,7 +23,10 @@ # worker-general (this happened — the bare form shipped and broke the general # worker at flag-off, where PG is not even involved). # `/app` is on PYTHONPATH (run-worker-docker.sh), so `scheduler.` resolves under both. -from scheduler import dashboard_metrics_tasks # noqa: F401, E402 (side-effect import) +# +# The same reasoning covers agent_kv_tasks, which registers the Agent-KV +# sweep/TTL-cleanup periodics (spec §5.4) on this worker type. +from scheduler import agent_kv_tasks, dashboard_metrics_tasks from shared.enums.status_enums import PipelineStatus from shared.enums.worker_enums import QueueName from shared.infrastructure.config import WorkerConfig @@ -47,6 +50,28 @@ # Import the exact backend logic to ensure consistency +# Both modules above are imported ONLY for the side effect of registering their +# @worker_task entries on this worker; nothing here calls into them. Naming them +# in a module-level tuple is what keeps them imported. +# +# A bare `noqa` directive is NOT enough, and that is not a style preference — it +# reproduced cause of this exact line being deleted once already. Two hooks +# interact: +# 1. ruff's isort (I001) merges two `from scheduler import X` statements into +# one parenthesised statement, which relocates each trailing noqa directive onto +# a MEMBER line. +# 2. F401 is reported against the statement, whose first line now carries no +# directive, so pycln (`[tool.pycln] all = true` — it removes side-effect +# imports by design) deletes both members. +# That is what happened in a79e9d69: pre-commit.ci silently dropped BOTH this +# import and the pre-existing dashboard-metrics one, un-registering the +# dashboard-metrics periodics that had been live since UN-3796 as collateral. +# A genuine reference cannot be fixed away by either hook. +# +# Deleting this tuple re-arms that failure, and it fails SILENTLY: the worker +# starts fine and only the scheduled messages are rejected as unknown tasks. +_PERIODIC_TASK_MODULES = (dashboard_metrics_tasks, agent_kv_tasks) + logger = WorkerLogger.get_logger(__name__) # Initialize worker configuration diff --git a/workers/shared/api/internal_client.py b/workers/shared/api/internal_client.py index ee9fbca7ac..5a82c61b60 100644 --- a/workers/shared/api/internal_client.py +++ b/workers/shared/api/internal_client.py @@ -1608,6 +1608,115 @@ def get_configuration( error=str(e), ) + # Agent-KV client methods (spec §5.3/§5.4) + def agent_kv_finalize( + self, + job_id: str, + org_id: str, + success: bool, + result: dict[str, Any] | None = None, + error: str = "", + usage_summary: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Terminalize an Agent-KV job via the internal finalize endpoint (Task 11). + + Idempotent server-side: a job already in a terminal state short-circuits + to ``{"finalized": False, ...}`` instead of rewriting its result. + + Args: + job_id: Agent-KV job ID. + org_id: Organization ID (required by the endpoint's body, not the + URL — mirrors ``StageReportView``/``FinalizeView``). + success: Whether the executor run succeeded. Must be a real bool — + the endpoint 400s on a non-bool value. + result: Engine result to persist as the job's output (success only). + error: Failure reason to persist (failure only). + usage_summary: Optional usage/cost summary to persist (success only). + + Returns: + Backend response: ``{"finalized": bool, "webhook_url": str, "status": str}``. + """ + payload: dict[str, Any] = {"org_id": org_id, "success": success} + if success: + payload["result"] = result or {} + if usage_summary is not None: + payload["usage_summary"] = usage_summary + else: + payload["error"] = error + return self.post( + f"v1/agent-kv/jobs/{job_id}/finalize/", + data=payload, + organization_id=org_id, + ) + + def agent_kv_stage_report( + self, + job_id: str, + org_id: str, + stage: str, + status: str, + seconds: float | None = None, + counters: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Report a pipeline stage for an Agent-KV job via the internal + stage-report endpoint (spec §5.3/§5.4). + + Idempotent server-side, mirroring ``agent_kv_finalize``: a job already + in a terminal state short-circuits to ``{"ok": True, "noop": True}`` + instead of recording the stage. + + Args: + job_id: Agent-KV job ID. + org_id: Organization ID (required by the endpoint's body, not the + URL — mirrors ``StageReportView``/``FinalizeView``). + stage: Pipeline stage name (e.g. ``"extract"``). + status: Stage status (e.g. ``"started"``, ``"completed"``). + seconds: Optional stage duration in seconds. + counters: Optional stage counters to record. + + Returns: + Backend response: ``{"ok": True}`` or ``{"ok": True, "noop": True}``. + """ + payload: dict[str, Any] = {"org_id": org_id, "stage": stage, "status": status} + if seconds is not None: + payload["seconds"] = seconds + if counters: + payload["counters"] = counters + return self.post( + f"v1/agent-kv/jobs/{job_id}/stage/", + data=payload, + organization_id=org_id, + ) + + def agent_kv_sweep(self) -> dict[str, Any]: + """Trigger the Agent-KV maintenance sweep via the internal endpoint + (spec §5.4, Fix 5/Fix 8). + + Platform-wide, like the endpoint itself (``SweepView``): no + ``org_id`` -- it terminalizes never-dispatched ``PENDING`` jobs and + stuck ``DISPATCHED``/``RUNNING`` jobs across every org in one call. + Idempotent and batch-capped server-side, so safe to call on a tight + schedule. + + Returns: + Backend response: ``{"swept": int, "timed_out": int}``. + """ + return self.post("v1/agent-kv/sweep/", data={}) + + def agent_kv_ttl_cleanup(self) -> dict[str, Any]: + """Trigger the Agent-KV TTL cleanup via the internal endpoint + (spec §5.4, Fix 5). + + Platform-wide, like the endpoint itself (``TTLCleanupView``): no + ``org_id`` -- it deletes staged input/result files for jobs past + ``expires_at`` and blanks their refs, across every org in one call. + Idempotent and batch-capped server-side. + + Returns: + Backend response: ``{"cleaned": int}``. + """ + return self.post("v1/agent-kv/ttl-cleanup/", data={}) + # Usage client methods (delegate to UsageAPIClient) def get_aggregated_token_count( self, file_execution_id: str, organization_id: str | None = None diff --git a/workers/shared/enums/worker_enums_base.py b/workers/shared/enums/worker_enums_base.py index 65465659c0..2620b8f5fc 100644 --- a/workers/shared/enums/worker_enums_base.py +++ b/workers/shared/enums/worker_enums_base.py @@ -185,6 +185,10 @@ class QueueName(str, Enum): # IDE callback queue (prompt studio post-execution callbacks) IDE_CALLBACK = "ide_callback" + # Agent-KV terminal-callback queue (agent_kv_complete / agent_kv_error; + # spec §5.3). Consumed by the IDE_CALLBACK worker alongside IDE_CALLBACK. + AGENT_KV_CALLBACK = "agent_kv_callback" + def to_env_var_name(self) -> str: """Convert queue name to environment variable name. diff --git a/workers/shared/infrastructure/config/registry.py b/workers/shared/infrastructure/config/registry.py index bfdfcf2e50..01c39aee73 100644 --- a/workers/shared/infrastructure/config/registry.py +++ b/workers/shared/infrastructure/config/registry.py @@ -70,6 +70,9 @@ class WorkerRegistry: ), WorkerType.IDE_CALLBACK: WorkerQueueConfig( primary_queue=QueueName.IDE_CALLBACK, + # Agent-KV terminal callbacks (agent_kv_complete/agent_kv_error) + # ride the same worker on their own dedicated queue (spec §5.3). + additional_queues=[QueueName.AGENT_KV_CALLBACK], ), } @@ -156,6 +159,8 @@ class WorkerRegistry: TaskRoute("ide_prompt_complete", QueueName.IDE_CALLBACK), TaskRoute("ide_prompt_error", QueueName.IDE_CALLBACK), TaskRoute("ide_callback.tasks.*", QueueName.IDE_CALLBACK), + TaskRoute("agent_kv_complete", QueueName.AGENT_KV_CALLBACK), + TaskRoute("agent_kv_error", QueueName.AGENT_KV_CALLBACK), ], ), } diff --git a/workers/shared/infrastructure/config/tests/__init__.py b/workers/shared/infrastructure/config/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/workers/shared/infrastructure/config/tests/test_registry.py b/workers/shared/infrastructure/config/tests/test_registry.py new file mode 100644 index 0000000000..80a7c63117 --- /dev/null +++ b/workers/shared/infrastructure/config/tests/test_registry.py @@ -0,0 +1,44 @@ +"""Tests for worker registry configuration. + +The registry is where a queue stops being a string in a shell script and +becomes something a worker actually subscribes to. For Agent-KV that matters +more than usual: `agent_kv_callback` carries the terminal callbacks, and a job +whose callback is never consumed runs to SUCCESS in the executor and then sits +in RUNNING forever -- the result is never persisted, the staged input is never +deleted, the concurrency slot is never released and the webhook never fires. +Nothing errors, because nothing failed. +""" + +from shared.enums.worker_enums import WorkerType +from shared.enums.worker_enums_base import QueueName +from shared.infrastructure.config.registry import WorkerRegistry + + +def test_ide_callback_worker_also_subscribes_to_the_agent_kv_callback_queue(): + cfg = WorkerRegistry._QUEUE_CONFIGS[WorkerType.IDE_CALLBACK] + assert QueueName.AGENT_KV_CALLBACK in cfg.additional_queues, ( + "the ide_callback worker does not subscribe to agent_kv_callback; " + "Agent-KV jobs would execute and then never terminalize" + ) + + +def test_both_agent_kv_terminal_callbacks_route_to_that_queue(): + """`dispatch_job` attaches both as Celery link/link_error. A route missing + for either leaves one half of the outcome space unhandled -- most visibly, + a failed job that never moves out of RUNNING. + """ + routing = WorkerRegistry._TASK_ROUTES[WorkerType.IDE_CALLBACK] + routed = {r.pattern: r.queue for r in routing.routes} + + assert routed["agent_kv_complete"] == QueueName.AGENT_KV_CALLBACK + assert routed["agent_kv_error"] == QueueName.AGENT_KV_CALLBACK + + +def test_no_worker_type_claims_the_unshipped_sandbox_queue(): + """This deployment ships no codegen sandbox (UN-4215 is the fast-follow). + + A registry entry for a worker that is never deployed is how a queue ends up + advertised and undrained, so the absence is asserted rather than assumed. + """ + assert not hasattr(QueueName, "SANDBOX_CODEGEN") + assert not hasattr(WorkerType, "SANDBOX") diff --git a/workers/shared/tests/test_agent_kv_client.py b/workers/shared/tests/test_agent_kv_client.py new file mode 100644 index 0000000000..86f02b8f35 --- /dev/null +++ b/workers/shared/tests/test_agent_kv_client.py @@ -0,0 +1,90 @@ +"""Tests for ``InternalAPIClient.agent_kv_stage_report`` (Agent-KV cloud +executor Task 2) and the worker package's dependency on +``unstract-agent-kv-schema``. + +Mirrors ``workers/tests/test_agent_kv_scheduler_tasks.py``'s +``TestInternalClientAgentKvMethods`` pattern for patching the client's HTTP +layer: ``patch.object(InternalAPIClient, "post", ...)`` plus +``InternalAPIClient.__new__(InternalAPIClient)`` to avoid running the real +``__init__`` (session/config setup unrelated to this method). +""" + +from unittest.mock import patch + +from shared.api import InternalAPIClient + + +class TestAgentKvStageReport: + def test_posts_minimal_body_to_the_stage_endpoint(self): + """seconds/counters are omitted from the body when not provided.""" + with patch.object(InternalAPIClient, "post", return_value={"ok": True}) as m_post: + client = InternalAPIClient.__new__(InternalAPIClient) + result = client.agent_kv_stage_report("job-1", "org-1", "extract", "started") + + m_post.assert_called_once_with( + "v1/agent-kv/jobs/job-1/stage/", + data={"org_id": "org-1", "stage": "extract", "status": "started"}, + organization_id="org-1", + ) + assert result == {"ok": True} + + def test_posts_full_body_with_seconds_and_counters(self): + with patch.object(InternalAPIClient, "post", return_value={"ok": True}) as m_post: + client = InternalAPIClient.__new__(InternalAPIClient) + result = client.agent_kv_stage_report( + "job-2", + "org-2", + "extract", + "completed", + seconds=1.5, + counters={"rows": 3}, + ) + + m_post.assert_called_once_with( + "v1/agent-kv/jobs/job-2/stage/", + data={ + "org_id": "org-2", + "stage": "extract", + "status": "completed", + "seconds": 1.5, + "counters": {"rows": 3}, + }, + organization_id="org-2", + ) + assert result == {"ok": True} + + def test_returns_the_parsed_noop_response_for_a_terminal_job(self): + """The backend answers {"ok": true, "noop": true} for a terminal job; + the client returns it unchanged (idempotent, no special-casing). + """ + with patch.object( + InternalAPIClient, "post", return_value={"ok": True, "noop": True} + ): + client = InternalAPIClient.__new__(InternalAPIClient) + result = client.agent_kv_stage_report("job-3", "org-3", "extract", "started") + + assert result == {"ok": True, "noop": True} + + def test_empty_counters_dict_is_omitted_like_none(self): + """``if counters:`` (not ``is not None``) mirrors the brief's + implementation exactly -- an empty dict is falsy and left out. + """ + with patch.object(InternalAPIClient, "post", return_value={"ok": True}) as m_post: + client = InternalAPIClient.__new__(InternalAPIClient) + client.agent_kv_stage_report( + "job-4", "org-4", "extract", "started", counters={} + ) + + m_post.assert_called_once_with( + "v1/agent-kv/jobs/job-4/stage/", + data={"org_id": "org-4", "stage": "extract", "status": "started"}, + organization_id="org-4", + ) + + +class TestAgentKvSchemaImport: + def test_agent_kv_schema_package_importable(self): + """The worker venv depends on unstract-agent-kv-schema (path dependency, + editable) so the schema compiler is importable inside worker code. + """ + import unstract.agent_kv_schema # noqa: F401 diff --git a/workers/shared/tests/test_webhook_notify.py b/workers/shared/tests/test_webhook_notify.py new file mode 100644 index 0000000000..1c0fddfb9b --- /dev/null +++ b/workers/shared/tests/test_webhook_notify.py @@ -0,0 +1,221 @@ +"""Tests for the SSRF-guarded webhook sender (spec §6.7). + +``requests`` is patched at the ``webhook_notify`` import site; DNS is patched +inside ``unstract.core.network.ssrf``, which is where the egress decision now +lives (this module no longer has a resolver of its own -- Greptile review #4 +replaced its hand-rolled ``_host_is_public`` with the shared guard). So no real +network/DNS activity occurs. + +The behaviours asserted here are therefore the SINK's contract -- scheme +policy, the ``allow_insecure`` waiver, status-code handling, and the fact that +an unsafe destination never reaches ``requests.post``. The guard's own +address-classification rules are covered by +``unstract/core/tests/test_ssrf_guard.py``; the cases kept below are the ones +this sink must keep refusing regardless of which implementation is behind it. +""" + +import socket +from unittest import mock + +from shared.utils import webhook_notify as wn + +from unstract.core.network import ssrf + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_private_ip_refused(m_gai, m_requests): + m_gai.return_value = [(2, 1, 6, "", ("10.0.0.5", 443))] + assert wn.send_webhook("https://internal.example/x", {"a": 1}) is False + assert not m_requests.post.called + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_metadata_ip_refused(m_gai, m_requests): + m_gai.return_value = [(2, 1, 6, "", ("169.254.169.254", 80))] + assert wn.send_webhook("https://md.example/x", {}) is False + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_cgnat_shared_address_space_refused(m_gai, m_requests): + """RFC 6598 100.64.0.0/10. `ipaddress.is_private` does not cover this + range, which is why the old local guard needed a hand-maintained + `_CGNAT_RANGE` constant for it (and why SonarCloud flagged that hardcoded + address). The shared guard gets it from `is_global`, an allowlist kept + against the IANA registries, so the range is covered without naming it. + """ + m_gai.return_value = [(2, 1, 6, "", ("100.64.0.1", 443))] + assert wn.send_webhook("https://cgnat.example/x", {}) is False + assert not m_requests.post.called + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_nat64_embedded_loopback_refused(m_gai, m_requests): + """``64:ff9b::7f00:1`` is the RFC 6052 NAT64 prefix carrying 127.0.0.1 in + its low 32 bits; on a deployment with a NAT64 route, that is what gets + dialled. + + A REGRESSION GUARD, not a fixed bug: the previous local guard refused this + too, via ``ipaddress.is_reserved`` (checked on 3.12.9 -- the claim in + Greptile review #4 that it was delivered to does not hold). It is pinned + here because that refusal was incidental to a version-dependent flag, + whereas the shared guard refuses it deliberately by re-checking the + embedded IPv4. Both implementations pass; only one of them means to. + """ + m_gai.return_value = [(socket.AF_INET6, 1, 6, "", ("64:ff9b::7f00:1", 443, 0, 0))] + assert wn.send_webhook("https://nat64.example/x", {}) is False + assert not m_requests.post.called + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_nat64_embedded_public_ip_still_allowed(m_gai, m_requests): + """The companion to the test above, and the case that actually CHANGES + behaviour. + + ``64:ff9b::5db8:d822`` embeds the public 93.184.216.34 -- a legitimate + destination through a NAT64 translator. The old guard refused it, by the + same blanket ``is_reserved`` that refused the loopback one: it could not + tell the two apart, so it rejected the whole prefix. This test fails + against that implementation and passes against the shared guard, which + judges the embedded address. + """ + m_gai.return_value = [(socket.AF_INET6, 1, 6, "", ("64:ff9b::5db8:d822", 443, 0, 0))] + m_requests.post.return_value.status_code = 200 + assert wn.send_webhook("https://nat64-ok.example/hook", {}) is True + + +@mock.patch.object(wn, "requests") +def test_localhost_subdomain_refused_without_dns(m_requests): + """RFC 6761 reserves ``*.localhost`` to loopback, and the shared guard + decides that WITHOUT a resolver. + + No resolver is patched in this test, which is the point: the old guard had + no such rule and could only reach the same answer through DNS, so its + verdict depended on the image's libc (glibc's nss-myhostname answers for + the whole subtree, musl only for the bare apex, a minimal container for + neither). Same outcome, reached by luck rather than by rule. + """ + assert wn.send_webhook("https://api.localhost/hook", {}) is False + assert not m_requests.post.called + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_normal_public_ip_still_allowed(m_gai, m_requests): + m_gai.return_value = [(2, 1, 6, "", ("93.184.216.34", 443))] + m_requests.post.return_value.status_code = 200 + assert wn.send_webhook("https://cgnat-sibling.example/x", {}) is True + + +def test_http_scheme_refused_by_default(): + assert wn.send_webhook("http://example.com/x", {}) is False + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_public_host_posted_no_redirects(m_gai, m_requests): + m_gai.return_value = [(2, 1, 6, "", ("93.184.216.34", 443))] + m_requests.post.return_value.status_code = 200 + assert wn.send_webhook("https://example.com/hook", {"job_id": "j"}) is True + kw = m_requests.post.call_args.kwargs + assert kw["allow_redirects"] is False + assert kw["timeout"] == 10 + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_delivery_error_returns_false_never_raises(m_gai, m_requests): + m_gai.return_value = [(2, 1, 6, "", ("93.184.216.34", 443))] + m_requests.post.side_effect = Exception("boom") + assert wn.send_webhook("https://example.com/hook", {}) is False + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_ipv6_loopback_refused(m_gai, m_requests): + """Pins the 4-tuple IPv6 sockaddr shape: (addr, port, flowinfo, scope_id).""" + m_gai.return_value = [(socket.AF_INET6, 1, 6, "", ("::1", 443, 0, 0))] + assert wn.send_webhook("https://v6.example/x", {}) is False + assert not m_requests.post.called + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_ipv6_public_posted(m_gai, m_requests): + m_gai.return_value = [(socket.AF_INET6, 1, 6, "", ("2606:4700::1111", 443, 0, 0))] + m_requests.post.return_value.status_code = 200 + assert wn.send_webhook("https://v6.example/hook", {}) is True + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_empty_resolution_refused(m_gai, m_requests): + m_gai.return_value = [] + assert wn.send_webhook("https://nowhere.example/x", {}) is False + assert not m_requests.post.called + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_multi_record_any_unsafe_refuses(m_gai, m_requests): + """One public + one private record for the same host: must refuse (ALL + resolved addresses must be public, not just the first). + """ + m_gai.return_value = [ + (2, 1, 6, "", ("93.184.216.34", 443)), + (2, 1, 6, "", ("10.0.0.5", 443)), + ] + assert wn.send_webhook("https://mixed.example/x", {}) is False + assert not m_requests.post.called + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_server_error_status_returns_false(m_gai, m_requests): + m_gai.return_value = [(2, 1, 6, "", ("93.184.216.34", 443))] + m_requests.post.return_value.status_code = 500 + assert wn.send_webhook("https://example.com/hook", {}) is False + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_not_found_status_returns_false(m_gai, m_requests): + m_gai.return_value = [(2, 1, 6, "", ("93.184.216.34", 443))] + m_requests.post.return_value.status_code = 404 + assert wn.send_webhook("https://example.com/hook", {}) is False + + +@mock.patch.object(wn, "requests") +@mock.patch.object(ssrf.socket, "getaddrinfo") +def test_allow_http_true_posts_public_host(m_gai, m_requests): + m_gai.return_value = [(2, 1, 6, "", ("93.184.216.34", 80))] + m_requests.post.return_value.status_code = 200 + assert wn.send_webhook("http://example.com/hook", {}, allow_http=True) is True + assert m_requests.post.called + + +class TestAllowInsecure: + """``allow_insecure`` waives scheme + public-host guards (test stacks only).""" + + @mock.patch.object(wn, "requests") + def test_http_private_host_delivered_when_insecure(self, m_requests): + m_requests.post.return_value = mock.Mock(status_code=200) + ok = wn.send_webhook( + "http://host.docker.internal:18099/hook", + {"job_id": "j", "status": "completed"}, + allow_insecure=True, + ) + assert ok is True + m_requests.post.assert_called_once() + + @mock.patch.object(wn, "requests") + def test_default_still_refuses_http_and_private(self, m_requests): + assert ( + wn.send_webhook("http://host.docker.internal:18099/hook", {"job_id": "j"}) + is False + ) + assert not m_requests.post.called diff --git a/workers/shared/utils/webhook_notify.py b/workers/shared/utils/webhook_notify.py new file mode 100644 index 0000000000..da41233119 --- /dev/null +++ b/workers/shared/utils/webhook_notify.py @@ -0,0 +1,85 @@ +"""Terminal-state webhook delivery for Agent-KV (spec §6.7). + +The egress decision is NOT made here. It belongs to +``unstract.core.network.ssrf``, the single place in the codebase that decides +whether a tenant-supplied URL may be dialled -- the same guard behind the +prompt-postprocessing and pipeline-notification sinks, which exists (per its own +docstring) "so a new sink does not carry its own copy of the rules". + +This module shipped with its own ``_host_is_public``, built from negative +``ipaddress`` flags. Greptile review #4 asked for the shared guard on the +grounds that a NAT64 literal such as ``64:ff9b::7f00:1`` -- the RFC 6052 +prefix carrying 127.0.0.1 in its low 32 bits -- slipped past it. **That +specific claim does not hold**: on the runtime this worker uses (3.12), +``ipaddress`` reports ``is_reserved`` for ``64:ff9b::/96``, so the local guard +already refused it. Verified against 3.12.9 rather than assumed. + +The move is still right, for the reasons the local copy's existence was wrong: + +- It refused in the WRONG DIRECTION too. ``64:ff9b::5db8:d822`` embeds the + public 93.184.216.34 -- a legitimate destination for a deployment with a + NAT64 route -- and the same ``is_reserved`` flag refused that as well. The + shared guard re-checks the embedded IPv4 (``_EMBEDS_IPV4``) and so separates + the two cases instead of rejecting the prefix wholesale. +- Its NAT64 refusal was ACCIDENTAL. ``is_reserved`` is not an SSRF control; it + is a version-dependent table (its IPv6 entries have moved between CPython + releases), and nothing recorded that this guard leaned on it. The shared + guard refuses on ``is_global`` -- an allowlist maintained against the IANA + registries -- plus an explicit embedded-IPv4 re-check. +- Enumerating what to refuse misses whatever belongs to none of the flags, and + this copy had already been patched once for RFC 6598 shared address space for + exactly that reason. The shared guard gets that range from ``is_global`` for + free, which also retires the hardcoded ``100.64.0.0`` SonarCloud flagged. +- It also brings rules this sink never had: ``*.localhost`` decided without a + resolver, credentials-in-URL, and the urlparse/urllib3 host disagreement that + determines which host the socket actually connects to. + +Residual accepted risk, unchanged and shared with every other sink: DNS is +resolved for the check and again by ``requests``, so a rebinding window exists. +The control for that is pod egress policy, not application code. The payload +carries only {job_id, status} and the response body is never read. +""" + +import json +import logging + +import requests + +from unstract.core.network.ssrf import is_safe_webhook_url, safe_host + +logger = logging.getLogger(__name__) + +_TIMEOUT = 10 + + +def send_webhook( + url: str, payload: dict, *, allow_http: bool = False, allow_insecure: bool = False +) -> bool: + """``allow_insecure`` waives BOTH guards (http scheme and non-public host). + + Test/dev stacks only -- it exists so the e2e lane can deliver to a + receiver on the compose host (host.docker.internal is a private + address). Production never sets it; the SSRF guards stay mandatory. + """ + try: + if allow_insecure: + # Scheme and destination both unchecked: this is the e2e escape + # hatch, and the only path on which `requests.post` is reached + # without the shared guard having approved the URL. + pass + elif not is_safe_webhook_url( + url, allowed_schemes=("http", "https") if allow_http else ("https",) + ): + # is_safe_webhook_url already logged the refusal reason and host. + return False + resp = requests.post( + url, + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=_TIMEOUT, + allow_redirects=False, + ) + return 200 <= resp.status_code < 300 + except Exception: + logger.warning("webhook delivery failed (host=%s)", safe_host(url), exc_info=True) + return False diff --git a/workers/tests/test_agent_kv_scheduler_tasks.py b/workers/tests/test_agent_kv_scheduler_tasks.py new file mode 100644 index 0000000000..7718a63f77 --- /dev/null +++ b/workers/tests/test_agent_kv_scheduler_tasks.py @@ -0,0 +1,95 @@ +"""Tests for the thin Agent-KV maintenance-periodic proxy tasks (spec §5.4). + +Mirrors ``test_dashboard_metrics_tasks.py``'s style: these tasks do nothing but +call the shared ``InternalAPIClient`` facade, so what's worth pinning is the +registered wire name (a mismatch means the PG consumer drops the message as an +unknown task -- the failure mode with no error at the enqueue site) and which +client method each task calls. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Import via the PACKAGE, matching `scheduler/tasks.py`'s +# `from scheduler import agent_kv_tasks` -- see that file's comment and +# `test_dashboard_metrics_tasks.py`'s identical note on why this form (not a +# bare `import agent_kv_tasks`) is the one both runtime import mechanisms +# converge on. +_WORKERS_ROOT = Path(__file__).resolve().parent.parent +if str(_WORKERS_ROOT) not in sys.path: + sys.path.insert(0, str(_WORKERS_ROOT)) + +from scheduler import agent_kv_tasks as akt # noqa: E402 +from shared.api import InternalAPIClient # noqa: E402 + + +class TestRegistration: + """The wire name is this task pair's own new contract (no pre-existing + Beat/PG-scheduler row to mirror) -- a rename here silently breaks whatever + PgPeriodicTask row an operator registered against the old name. + """ + + @pytest.mark.parametrize( + "name,func", + [ + ("agent_kv.sweep", "agent_kv_sweep"), + ("agent_kv.ttl_cleanup", "agent_kv_ttl_cleanup"), + ], + ) + def test_task_is_registered_under_its_wire_name(self, name, func): + assert getattr(akt, func).name == name + + +class TestApiClientAcquisition: + def test_get_api_client_returns_an_internal_api_client(self): + with patch("shared.api.InternalAPIClient") as mock_cls: + akt._get_api_client() + mock_cls.assert_called_once_with() + + +class TestCallContract: + def test_sweep_calls_the_sweep_client_method(self): + mock_api = MagicMock() + mock_api.agent_kv_sweep.return_value = {"swept": 3, "timed_out": 1} + with patch.object(akt, "_get_api_client", return_value=mock_api): + result = akt.agent_kv_sweep() + mock_api.agent_kv_sweep.assert_called_once_with() + assert result == {"swept": 3, "timed_out": 1} + + def test_ttl_cleanup_calls_the_ttl_cleanup_client_method(self): + mock_api = MagicMock() + mock_api.agent_kv_ttl_cleanup.return_value = {"cleaned": 2} + with patch.object(akt, "_get_api_client", return_value=mock_api): + result = akt.agent_kv_ttl_cleanup() + mock_api.agent_kv_ttl_cleanup.assert_called_once_with() + assert result == {"cleaned": 2} + + +class TestInternalClientAgentKvMethods: + """The client methods (``workers/shared/api/internal_client.py``) the tasks + above call -- pinned separately so a change to either the endpoint path or + the call contract fails at the right layer. + """ + + def test_agent_kv_sweep_posts_to_the_sweep_endpoint(self): + with patch.object( + InternalAPIClient, "post", return_value={"swept": 1, "timed_out": 0} + ) as m_post: + client = InternalAPIClient.__new__(InternalAPIClient) + result = client.agent_kv_sweep() + m_post.assert_called_once_with("v1/agent-kv/sweep/", data={}) + assert result == {"swept": 1, "timed_out": 0} + + def test_agent_kv_ttl_cleanup_posts_to_the_ttl_cleanup_endpoint(self): + with patch.object( + InternalAPIClient, "post", return_value={"cleaned": 1} + ) as m_post: + client = InternalAPIClient.__new__(InternalAPIClient) + result = client.agent_kv_ttl_cleanup() + m_post.assert_called_once_with("v1/agent-kv/ttl-cleanup/", data={}) + assert result == {"cleaned": 1} diff --git a/workers/tests/test_queue_consumer_wiring.py b/workers/tests/test_queue_consumer_wiring.py new file mode 100644 index 0000000000..0c496bdd32 --- /dev/null +++ b/workers/tests/test_queue_consumer_wiring.py @@ -0,0 +1,190 @@ +"""Guard: every queue we dispatch to must have a consumer in the deployed fleet. + +This is the OSS analogue of the cloud chart's ``validate-pg-worker-fleet.yaml``. +It exists because the failure it catches is **silent**: since UN-4046 made the +PG transport unconditional, a queue whose consumer is not configured still +*accepts* work -- ``enqueue_task`` succeeds, rows land in ``pg_queue_message``, +and nothing errors at the producer. The job simply sits in DISPATCHED forever. + +That is exactly what happened to ``celery_executor_agentic_kv``: the Agent-KV +branch was written when the Celery executor was live, so its queue was wired +into ``CELERY_QUEUES_EXECUTOR`` -- a variable read only by +``workers/executor/worker.py`` (the now-disabled Celery worker). The live +``pg-queue-consumer`` reads ``WORKER_PG_QUEUE_CONSUMER_QUEUE``, which did not +list it. + +Each test therefore asserts the queue is present in the variable the *running* +consumer actually reads, at every site that configures one. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEV_COMPOSE = REPO_ROOT / "docker" / "docker-compose.yaml" +TEST_COMPOSE = REPO_ROOT / "tests" / "compose" / "docker-compose.test.yaml" +RUN_WORKER = REPO_ROOT / "workers" / "run-worker.sh" + +# The queue the `kv` extractor WOULD dispatch onto, and which this deployment +# deliberately does not serve: the `agentic_kv` plugin ships on a separate PR. +# `kv` is absent from EXTRACTOR_ROUTES so a submit is refused at the serializer +# with a 400 -- these assertions are the second half of that, pinning that no +# fleet advertises a consumer for it. A queue that is listed but unserved +# accepts work and drains nothing, with no error at the producer: the job sits +# in DISPATCHED forever. That failure has already cost this team ~30 hours of +# firings with zero executions, which is why it is guarded from both ends. +UNSERVED_KV_QUEUE = "celery_executor_agentic_kv" + +# The terminal-callback queue. Its consumer is what moves a job out of +# RUNNING: finalize persists the result, deletes the staged input, releases +# the concurrency slot and fires the webhook. +AGENT_KV_CALLBACK_QUEUE = "agent_kv_callback" + +# The queue the backend dispatches Agent-KV TABLE extraction onto. Same queue +# the IDE table path already uses -- ExecutionContext(executor_name= +# "agentic_table") -> celery_executor_agentic_table -- which is exactly why the +# API path reuses that executor name rather than introducing one of its own. +# It was already wired for the IDE; these assertions keep it wired now that a +# second, paid entry point depends on it. +AGENT_TABLE_QUEUE = "celery_executor_agentic_table" + +# The env var the live PG consumer reads. Named here so a future rename has to +# touch this constant rather than silently bypassing every assertion below. +PG_QUEUE_VAR = "WORKER_PG_QUEUE_CONSUMER_QUEUE" + +# Read only by the disabled Celery executor. Setting a queue here does NOT give +# it a consumer -- asserting its absence is what makes this suite catch the +# original bug rather than a cosmetic rename of it. +DEAD_CELERY_VAR = "CELERY_QUEUES_EXECUTOR" + + +def _service_env(compose_path: Path, service: str) -> dict[str, str]: + """Return ``service``'s environment as a dict, from a compose file.""" + doc = yaml.safe_load(compose_path.read_text()) + env = (doc.get("services", {}).get(service) or {}).get("environment") or [] + if isinstance(env, dict): # mapping form + return {k: str(v) for k, v in env.items()} + out: dict[str, str] = {} + for item in env: # list form: "KEY=value" + key, _, value = str(item).partition("=") + out[key] = value + return out + + +def _queues(raw: str) -> set[str]: + """Queue names from a consumer queue list, ignoring any ``${VAR:-default}`` + wrapper the compose file uses to keep the value overridable. + """ + inner = re.sub(r"^\$\{[^:}]+:-(.*)\}$", r"\1", raw.strip()) + return {q.strip() for q in inner.split(",") if q.strip()} + + +@pytest.mark.parametrize( + ("compose_path", "label"), + [(DEV_COMPOSE, "dev compose"), (TEST_COMPOSE, "e2e test compose")], +) +def test_no_fleet_advertises_the_unserved_kv_queue(compose_path, label): + """The executor fleet must not list a queue with no plugin behind it.""" + env = _service_env(compose_path, "worker-pg-executor") + raw = env.get(PG_QUEUE_VAR) + assert raw is not None, ( + f"{label}: worker-pg-executor sets no {PG_QUEUE_VAR}; the running " + f"pg-queue-consumer would fall back to a default of unknown content" + ) + assert UNSERVED_KV_QUEUE not in _queues(raw), ( + f"{label}: {UNSERVED_KV_QUEUE} is advertised but this deployment " + f"carries no agentic_kv plugin to drain it. Work routed there is " + f"accepted and never runs, silently. Remove it from {PG_QUEUE_VAR}, " + f"or ship the plugin." + ) + + +def test_test_compose_does_not_wire_the_queue_onto_the_dead_celery_var(): + """Regression: the e2e override once set the Celery-only variable. + + That drained nothing on the PG transport. If someone re-adds it, the queue + looks configured while the lane still hangs -- so fail loudly. + """ + env = _service_env(TEST_COMPOSE, "worker-pg-executor") + assert DEAD_CELERY_VAR not in env, ( + f"{DEAD_CELERY_VAR} is read only by the disabled Celery executor " + f"(workers/executor/worker.py). Queues belong in {PG_QUEUE_VAR}." + ) + + +def test_run_worker_pg_executor_role_omits_the_unserved_kv_queue(): + """The host-run fleet's hardcoded role default must match the compose stacks. + + ``run-worker.sh`` carries its own PG_CONSUMER_ROLES map; a queue listed + here but not in compose is just as unconsumed for anyone running the fleet + directly. + """ + text = RUN_WORKER.read_text() + match = re.search(r'\["\$PG_ROLE_EXECUTOR"\]="executor;([^"]+)"', text) + assert match, "could not find the PG_ROLE_EXECUTOR entry in run-worker.sh" + assert UNSERVED_KV_QUEUE not in _queues(match.group(1)), ( + f"run-worker.sh's pg-executor role advertises {UNSERVED_KV_QUEUE}; a " + f"host-run fleet would accept Agent-KV work and never drain it." + ) + + +@pytest.mark.parametrize( + ("compose_path", "label"), + [(DEV_COMPOSE, "dev compose"), (TEST_COMPOSE, "e2e test compose")], +) +def test_pg_executor_consumes_the_agent_table_queue(compose_path, label): + """Agent-KV table jobs ride the IDE table executor's queue (spec R1).""" + env = _service_env(compose_path, "worker-pg-executor") + raw = env.get(PG_QUEUE_VAR) + assert raw is not None, ( + f"{label}: worker-pg-executor sets no {PG_QUEUE_VAR}; the running " + f"pg-queue-consumer would fall back to a default that omits " + f"{AGENT_TABLE_QUEUE}" + ) + assert AGENT_TABLE_QUEUE in _queues(raw), ( + f"{label}: {AGENT_TABLE_QUEUE} has no consumer -- both IDE table " + f"prompts and Agent-KV table jobs will sit in DISPATCHED forever with " + f"no error at the producer. Add it to {PG_QUEUE_VAR}." + ) + + +def test_run_worker_pg_executor_role_lists_the_agent_table_queue(): + text = RUN_WORKER.read_text() + match = re.search(r'\["\$PG_ROLE_EXECUTOR"\]="executor;([^"]+)"', text) + assert match, "could not find the PG_ROLE_EXECUTOR entry in run-worker.sh" + assert AGENT_TABLE_QUEUE in _queues(match.group(1)), ( + f"run-worker.sh's pg-executor role omits {AGENT_TABLE_QUEUE}; a " + f"host-run fleet would accept table work and never drain it." + ) + + +def test_pg_ide_callback_drains_the_agent_kv_callback_queue(): + """Without this the job runs to SUCCESS and then never completes. + + The executor enqueues its terminal continuation onto `agent_kv_callback`; + if no consumer drains it the row sits in pg_queue_message and the job stays + RUNNING forever, with nothing logged at the producer. The Celery + ide_callback worker has carried both queues since the callbacks landed -- + its PG twin did not, and the cloud chart wires it correctly, so only the + OSS stacks were affected. Found by running a real job end to end. + """ + env = _service_env(DEV_COMPOSE, "worker-pg-ide-callback") + raw = env.get(PG_QUEUE_VAR) + assert raw is not None, "worker-pg-ide-callback sets no consumer queue" + assert AGENT_KV_CALLBACK_QUEUE in _queues(raw), ( + f"{AGENT_KV_CALLBACK_QUEUE} has no consumer -- Agent-KV jobs will " + f"finish executing and never leave RUNNING." + ) + + +def test_run_worker_pg_ide_callback_role_drains_the_callback_queue(): + text = RUN_WORKER.read_text() + match = re.search(r'\["\$PG_ROLE_IDE_CALLBACK"\]="ide_callback;([^"]+)"', text) + assert match, "could not find the PG_ROLE_IDE_CALLBACK entry in run-worker.sh" + assert AGENT_KV_CALLBACK_QUEUE in _queues(match.group(1)) diff --git a/workers/tests/test_worker_task_module_imports.py b/workers/tests/test_worker_task_module_imports.py index df54943500..021fd8e0e1 100644 --- a/workers/tests/test_worker_task_module_imports.py +++ b/workers/tests/test_worker_task_module_imports.py @@ -27,6 +27,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -81,8 +82,11 @@ def _run_probe(source: str, workers: list[str] | None = None) -> dict[str, str]: proc = subprocess.run( - [sys.executable, "-c", - source.format(root=str(_WORKERS_ROOT), workers=workers or _TASK_MODULES)], + [ + sys.executable, + "-c", + source.format(root=str(_WORKERS_ROOT), workers=workers or _TASK_MODULES), + ], capture_output=True, text=True, cwd=str(_WORKERS_ROOT), @@ -90,7 +94,9 @@ def _run_probe(source: str, workers: list[str] | None = None) -> dict[str, str]: ) marker = [ln for ln in proc.stdout.splitlines() if ln.startswith("RESULT")] if not marker: - pytest.fail(f"probe did not complete.\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}") + pytest.fail( + f"probe did not complete.\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" + ) return json.loads(marker[-1][len("RESULT") :]) @@ -122,35 +128,55 @@ def test_every_tasks_module_imports_by_file_path() -> None: ) -def test_scheduler_registers_the_metrics_proxies_under_their_wire_names() -> None: - """The broken import is a SIDE-EFFECT import — it must actually register. - - Asserting only that scheduler.tasks imports would still pass if someone 'fixed' it - by deleting the import, silently unregistering the three dashboard-metrics proxies - that the PG metrics consumer resolves BY NAME. +def test_scheduler_tasks_registers_every_periodic_under_its_wire_name() -> None: + """Loading ``scheduler/tasks.py`` must REGISTER the periodic proxies, not + merely import without error. + + This replaces a test that stated this exact intent -- "asserting only that + scheduler.tasks imports would still pass if someone 'fixed' it by deleting + the import" -- and then imported ``scheduler.dashboard_metrics_tasks`` + DIRECTLY, which registers the tasks by itself. So it passed no matter what + ``scheduler/tasks.py`` contained, and it passed through the very regression + it was written to catch: in a79e9d69 pre-commit.ci's auto-fix deleted BOTH + side-effect imports from scheduler/tasks.py (ruff's isort merged the two + `from scheduler import ...` statements, which relocated each `# noqa` onto a + member line where it no longer suppresses the statement-level F401, and + pycln -- `all = true` -- then removed both). Nothing went red. + + The failure mode is silent: the worker boots fine and only the scheduled + messages are rejected as unknown tasks, so dashboard metrics stop updating + and Agent-KV jobs stop being swept with no error anywhere near the cause. + Hence going through ``scheduler.tasks`` -- what a booting worker actually + loads -- and asserting against the Celery registry rather than the modules. """ source = """ import importlib, json, sys sys.path.insert(0, {root!r}) -m = importlib.import_module("scheduler.dashboard_metrics_tasks") -print("RESULT" + json.dumps({{ - "aggregate": m.dashboard_metrics_aggregate.name, - "hourly": m.dashboard_metrics_cleanup_hourly.name, - "daily": m.dashboard_metrics_cleanup_daily.name, -}})) +importlib.import_module("scheduler.tasks") +from celery import current_app +print("RESULT" + json.dumps(sorted( + n for n in current_app.tasks + if n.startswith("dashboard_metrics.") or n.startswith("agent_kv.") +))) """ proc = subprocess.run( [sys.executable, "-c", source.format(root=str(_WORKERS_ROOT))], capture_output=True, text=True, cwd=str(_WORKERS_ROOT), + # Resolved at import with no default; unset, scheduler.tasks raises + # before any registration and this would fail for the wrong reason. + env={**os.environ, "INTERNAL_API_BASE_URL": "http://localhost:8000"}, timeout=300, ) marker = [ln for ln in proc.stdout.splitlines() if ln.startswith("RESULT")] assert marker, f"probe failed:\n{proc.stdout}\n{proc.stderr}" - names = json.loads(marker[-1][len("RESULT") :]) - assert names == { - "aggregate": "dashboard_metrics.aggregate_from_sources", - "hourly": "dashboard_metrics.cleanup_hourly_data", - "daily": "dashboard_metrics.cleanup_daily_data", - } + assert json.loads(marker[-1][len("RESULT") :]) == [ + # Pinned to verbatim Beat/PG-scheduler rows -- the consumer resolves + # these BY NAME, so a rename here is a silent un-registration too. + "agent_kv.sweep", + "agent_kv.ttl_cleanup", + "dashboard_metrics.aggregate_from_sources", + "dashboard_metrics.cleanup_daily_data", + "dashboard_metrics.cleanup_hourly_data", + ] diff --git a/workers/uv.lock b/workers/uv.lock index d80d0c223f..ec842d77db 100644 --- a/workers/uv.lock +++ b/workers/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 1 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -19,9 +19,9 @@ dependencies = [ { name = "azure-storage-blob" }, { name = "fsspec" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/1e/6d5146676044247af566fa5843b335b1a647e6446070cec9c8b61c31b369/adlfs-2024.7.0.tar.gz", hash = "sha256:106995b91f0eb5e775bcd5957d180d9a14faef3271a063b1f65c66fd5ab05ddf", size = 48588 } +sdist = { url = "https://files.pythonhosted.org/packages/b4/1e/6d5146676044247af566fa5843b335b1a647e6446070cec9c8b61c31b369/adlfs-2024.7.0.tar.gz", hash = "sha256:106995b91f0eb5e775bcd5957d180d9a14faef3271a063b1f65c66fd5ab05ddf", size = 48588, upload-time = "2024-07-22T12:10:33.849Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/51/a71c457bd0bc8af3e522b6999ff300852c7c446e384fd9904b0794f875df/adlfs-2024.7.0-py3-none-any.whl", hash = "sha256:2005c8e124fda3948f2a6abb2dbebb2c936d2d821acaca6afd61932edfa9bc07", size = 41349 }, + { url = "https://files.pythonhosted.org/packages/6f/51/a71c457bd0bc8af3e522b6999ff300852c7c446e384fd9904b0794f875df/adlfs-2024.7.0-py3-none-any.whl", hash = "sha256:2005c8e124fda3948f2a6abb2dbebb2c936d2d821acaca6afd61932edfa9bc07", size = 41349, upload-time = "2024-07-22T12:10:32.226Z" }, ] [[package]] @@ -34,9 +34,9 @@ dependencies = [ { name = "botocore" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/17/2f6305cc52976dea8156b56badc3602f162f86693a6cc8badc20d2c5cfe6/aiobotocore-2.13.3.tar.gz", hash = "sha256:ac5620f93cc3e7c2aef7c67ba2bb74035ff8d49ee2325821daed13b3dd82a473", size = 106736 } +sdist = { url = "https://files.pythonhosted.org/packages/ea/17/2f6305cc52976dea8156b56badc3602f162f86693a6cc8badc20d2c5cfe6/aiobotocore-2.13.3.tar.gz", hash = "sha256:ac5620f93cc3e7c2aef7c67ba2bb74035ff8d49ee2325821daed13b3dd82a473", size = 106736, upload-time = "2024-08-22T20:42:13.086Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/98/1c1a1ba5d74f87cbabbf207d36a98551b082b8b187dc35f7808b43f4cf1a/aiobotocore-2.13.3-py3-none-any.whl", hash = "sha256:1272f765fd9414e1a68f8add71978367db94e17e36c3bf629cf1153eb5141fb9", size = 77194 }, + { url = "https://files.pythonhosted.org/packages/81/98/1c1a1ba5d74f87cbabbf207d36a98551b082b8b187dc35f7808b43f4cf1a/aiobotocore-2.13.3-py3-none-any.whl", hash = "sha256:1272f765fd9414e1a68f8add71978367db94e17e36c3bf629cf1153eb5141fb9", size = 77194, upload-time = "2024-08-22T20:42:09.784Z" }, ] [package.optional-dependencies] @@ -48,9 +48,9 @@ boto3 = [ name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] @@ -67,99 +67,99 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690 }, - { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484 }, - { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949 }, - { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282 }, - { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511 }, - { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680 }, - { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646 }, - { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122 }, - { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127 }, - { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210 }, - { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848 }, - { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102 }, - { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205 }, - { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219 }, - { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629 }, - { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481 }, - { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845 }, - { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050 }, - { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269 }, - { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166 }, - { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263 }, - { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299 }, - { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235 }, - { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883 }, - { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473 }, - { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190 }, - { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478 }, - { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092 }, - { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546 }, - { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250 }, - { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289 }, - { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706 }, - { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652 }, - { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239 }, - { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161 }, - { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759 }, - { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025 }, - { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477 }, - { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069 }, - { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518 }, - { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676 }, - { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067 }, - { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774 }, - { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134 }, - { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201 }, - { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766 }, - { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557 }, - { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168 }, - { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957 }, - { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149 }, - { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685 }, - { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911 }, - { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929 }, - { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112 }, - { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500 }, - { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940 }, - { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413 }, - { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748 }, - { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564 }, - { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080 }, - { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813 }, - { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872 }, - { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030 }, - { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669 }, - { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403 }, - { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413 }, - { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135 }, - { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742 }, - { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371 }, - { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623 }, - { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515 }, - { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906 }, - { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849 }, - { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496 }, - { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746 }, - { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810 }, - { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895 }, - { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833 }, - { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251 }, - { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180 }, - { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528 }, - { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280 }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] name = "aioitertools" version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182 }, + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, ] [[package]] @@ -170,18 +170,18 @@ dependencies = [ { name = "frozenlist" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "aiosqlite" version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405 }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] [[package]] @@ -191,27 +191,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013 } +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944 }, + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, ] [[package]] name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -222,76 +222,76 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 } +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] [[package]] name = "appdirs" version = "1.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] [[package]] name = "asn1crypto" version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080 } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080, upload-time = "2022-03-15T14:46:52.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045 }, + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] [[package]] name = "asyncpg" version = "0.31.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042 }, - { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504 }, - { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241 }, - { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321 }, - { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685 }, - { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858 }, - { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852 }, - { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175 }, - { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111 }, - { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928 }, - { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067 }, - { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156 }, - { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636 }, - { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079 }, - { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606 }, - { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569 }, - { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867 }, - { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349 }, - { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428 }, - { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678 }, - { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505 }, - { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744 }, - { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251 }, - { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901 }, - { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280 }, - { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931 }, - { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608 }, - { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738 }, - { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026 }, - { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426 }, - { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495 }, - { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062 }, +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] [[package]] name = "attrs" version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] @@ -301,9 +301,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368, upload-time = "2026-05-04T08:11:31.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473 }, + { url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473, upload-time = "2026-05-04T08:11:30.354Z" }, ] [[package]] @@ -314,9 +314,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/fe/5c7710bc611a4070d06ba801de9a935cc87c3d4b689c644958047bdf2cba/azure_core-1.38.2.tar.gz", hash = "sha256:67562857cb979217e48dc60980243b61ea115b77326fa93d83b729e7ff0482e7", size = 363734 } +sdist = { url = "https://files.pythonhosted.org/packages/00/fe/5c7710bc611a4070d06ba801de9a935cc87c3d4b689c644958047bdf2cba/azure_core-1.38.2.tar.gz", hash = "sha256:67562857cb979217e48dc60980243b61ea115b77326fa93d83b729e7ff0482e7", size = 363734, upload-time = "2026-02-18T19:33:05.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/23/6371a551800d3812d6019cd813acd985f9fac0fedc1290129211a73da4ae/azure_core-1.38.2-py3-none-any.whl", hash = "sha256:074806c75cf239ea284a33a66827695ef7aeddac0b4e19dda266a93e4665ead9", size = 217957 }, + { url = "https://files.pythonhosted.org/packages/42/23/6371a551800d3812d6019cd813acd985f9fac0fedc1290129211a73da4ae/azure_core-1.38.2-py3-none-any.whl", hash = "sha256:074806c75cf239ea284a33a66827695ef7aeddac0b4e19dda266a93e4665ead9", size = 217957, upload-time = "2026-02-18T19:33:07.696Z" }, ] [[package]] @@ -328,9 +328,9 @@ dependencies = [ { name = "msal" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/ff/61369d06422b5ac48067215ff404841342651b14a89b46c8d8e1507c8f17/azure-datalake-store-0.0.53.tar.gz", hash = "sha256:05b6de62ee3f2a0a6e6941e6933b792b800c3e7f6ffce2fc324bc19875757393", size = 71430 } +sdist = { url = "https://files.pythonhosted.org/packages/22/ff/61369d06422b5ac48067215ff404841342651b14a89b46c8d8e1507c8f17/azure-datalake-store-0.0.53.tar.gz", hash = "sha256:05b6de62ee3f2a0a6e6941e6933b792b800c3e7f6ffce2fc324bc19875757393", size = 71430, upload-time = "2023-05-10T21:17:05.665Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/2a/75f56b14f115189155cf12e46b366ad1fe3357af5a1a7c09f7446662d617/azure_datalake_store-0.0.53-py2.py3-none-any.whl", hash = "sha256:a30c902a6e360aa47d7f69f086b426729784e71c536f330b691647a51dc42b2b", size = 55308 }, + { url = "https://files.pythonhosted.org/packages/88/2a/75f56b14f115189155cf12e46b366ad1fe3357af5a1a7c09f7446662d617/azure_datalake_store-0.0.53-py2.py3-none-any.whl", hash = "sha256:a30c902a6e360aa47d7f69f086b426729784e71c536f330b691647a51dc42b2b", size = 55308, upload-time = "2023-05-10T21:17:02.629Z" }, ] [[package]] @@ -344,9 +344,9 @@ dependencies = [ { name = "msal-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709 } +sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423 }, + { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" }, ] [[package]] @@ -359,18 +359,18 @@ dependencies = [ { name = "isodate" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/24/072ba8e27b0e2d8fec401e9969b429d4f5fc4c8d4f0f05f4661e11f7234a/azure_storage_blob-12.28.0.tar.gz", hash = "sha256:e7d98ea108258d29aa0efbfd591b2e2075fa1722a2fae8699f0b3c9de11eff41", size = 604225 } +sdist = { url = "https://files.pythonhosted.org/packages/71/24/072ba8e27b0e2d8fec401e9969b429d4f5fc4c8d4f0f05f4661e11f7234a/azure_storage_blob-12.28.0.tar.gz", hash = "sha256:e7d98ea108258d29aa0efbfd591b2e2075fa1722a2fae8699f0b3c9de11eff41", size = 604225, upload-time = "2026-01-06T23:48:57.282Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499 }, + { url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499, upload-time = "2026-01-06T23:48:58.995Z" }, ] [[package]] name = "backoff" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 }, + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] [[package]] @@ -385,75 +385,75 @@ dependencies = [ { name = "platformdirs" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/51/08fb68d23f4b0f6256fe85dc86e9576941550f890b079352fba719e07b39/banks-2.4.2.tar.gz", hash = "sha256:cda6013bd377ea7b701933578bfb9370fc21ad70bc13cedfc3f5cb2c034ca3dc", size = 188633 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/51/08fb68d23f4b0f6256fe85dc86e9576941550f890b079352fba719e07b39/banks-2.4.2.tar.gz", hash = "sha256:cda6013bd377ea7b701933578bfb9370fc21ad70bc13cedfc3f5cb2c034ca3dc", size = 188633, upload-time = "2026-04-27T12:15:22.021Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b6/8dc5477681b782e2f99de703e7a99828883364b9e03a60d3e2c47053d56a/banks-2.4.2-py3-none-any.whl", hash = "sha256:5fe407cc48c101f3e13d1cf732b83b8246003337612f13c0705d2e81f6faffb7", size = 35050 }, + { url = "https://files.pythonhosted.org/packages/00/b6/8dc5477681b782e2f99de703e7a99828883364b9e03a60d3e2c47053d56a/banks-2.4.2-py3-none-any.whl", hash = "sha256:5fe407cc48c101f3e13d1cf732b83b8246003337612f13c0705d2e81f6faffb7", size = 35050, upload-time = "2026-04-27T12:15:20.785Z" }, ] [[package]] name = "bcrypt" version = "5.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806 }, - { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626 }, - { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853 }, - { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793 }, - { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930 }, - { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194 }, - { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381 }, - { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750 }, - { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757 }, - { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740 }, - { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197 }, - { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974 }, - { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498 }, - { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853 }, - { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626 }, - { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862 }, - { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544 }, - { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787 }, - { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753 }, - { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587 }, - { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178 }, - { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295 }, - { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700 }, - { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034 }, - { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766 }, - { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449 }, - { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310 }, - { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761 }, - { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553 }, - { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009 }, - { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029 }, - { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907 }, - { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500 }, - { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412 }, - { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486 }, - { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940 }, - { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776 }, - { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922 }, - { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367 }, - { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187 }, - { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752 }, - { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881 }, - { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931 }, - { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313 }, - { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290 }, - { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253 }, - { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084 }, - { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185 }, - { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656 }, - { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662 }, - { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240 }, - { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152 }, - { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284 }, - { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643 }, - { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698 }, - { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725 }, - { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912 }, - { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953 }, +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, ] [[package]] @@ -464,27 +464,27 @@ dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721 }, + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] [[package]] name = "bidict" version = "0.23.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093 } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764 }, + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, ] [[package]] name = "billiard" version = "4.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537 } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070 }, + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, ] [[package]] @@ -499,24 +499,24 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155 } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920 }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499 }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994 }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867 }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124 }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034 }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503 }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557 }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766 }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140 }, - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234 }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522 }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824 }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855 }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109 }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542 }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] @@ -528,9 +528,9 @@ dependencies = [ { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/c1/f80cfbe564c89cdb080cd9ac2079ce05a2fac869bf8fbc45929ed3190da9/boto3-1.34.162.tar.gz", hash = "sha256:873f8f5d2f6f85f1018cbb0535b03cceddc7b655b61f66a0a56995238804f41f", size = 108585 } +sdist = { url = "https://files.pythonhosted.org/packages/74/c1/f80cfbe564c89cdb080cd9ac2079ce05a2fac869bf8fbc45929ed3190da9/boto3-1.34.162.tar.gz", hash = "sha256:873f8f5d2f6f85f1018cbb0535b03cceddc7b655b61f66a0a56995238804f41f", size = 108585, upload-time = "2024-08-15T19:25:38.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/41/faa5081761be3bac3999f912996c14c4dc9d06eab86c234bd6441f54bd64/boto3-1.34.162-py3-none-any.whl", hash = "sha256:d6f6096bdab35a0c0deff469563b87d184a28df7689790f7fe7be98502b7c590", size = 139174 }, + { url = "https://files.pythonhosted.org/packages/65/41/faa5081761be3bac3999f912996c14c4dc9d06eab86c234bd6441f54bd64/boto3-1.34.162-py3-none-any.whl", hash = "sha256:d6f6096bdab35a0c0deff469563b87d184a28df7689790f7fe7be98502b7c590", size = 139174, upload-time = "2024-08-15T19:25:35.384Z" }, ] [[package]] @@ -542,9 +542,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/de/17d672eac6725da49bd5832e3bd2f74c4d212311cd393fd56b59f51a4e86/botocore-1.34.162.tar.gz", hash = "sha256:adc23be4fb99ad31961236342b7cbf3c0bfc62532cd02852196032e8c0d682f3", size = 12676693 } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/17d672eac6725da49bd5832e3bd2f74c4d212311cd393fd56b59f51a4e86/botocore-1.34.162.tar.gz", hash = "sha256:adc23be4fb99ad31961236342b7cbf3c0bfc62532cd02852196032e8c0d682f3", size = 12676693, upload-time = "2024-08-15T19:25:25.162Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/47/e35f788047c91110f48703a6254e5c84e33111b3291f7b57a653ca00accf/botocore-1.34.162-py3-none-any.whl", hash = "sha256:2d918b02db88d27a75b48275e6fb2506e9adaaddbec1ffa6a8a0898b34e769be", size = 12468049 }, + { url = "https://files.pythonhosted.org/packages/bc/47/e35f788047c91110f48703a6254e5c84e33111b3291f7b57a653ca00accf/botocore-1.34.162-py3-none-any.whl", hash = "sha256:2d918b02db88d27a75b48275e6fb2506e9adaaddbec1ffa6a8a0898b34e769be", size = 12468049, upload-time = "2024-08-15T19:25:18.301Z" }, ] [[package]] @@ -555,9 +555,9 @@ dependencies = [ { name = "boxsdk", extra = ["jwt"] }, { name = "fsspec" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/de/1c5e0faec600538f6a1d41c7ce7834cacddb2237923e30ddb225254b74b9/boxfs-0.2.1.tar.gz", hash = "sha256:c1889e12f53be3216b44f088237ac0f367a7a759a53b01b0c0edf2b3d694e50f", size = 9523 } +sdist = { url = "https://files.pythonhosted.org/packages/be/de/1c5e0faec600538f6a1d41c7ce7834cacddb2237923e30ddb225254b74b9/boxfs-0.2.1.tar.gz", hash = "sha256:c1889e12f53be3216b44f088237ac0f367a7a759a53b01b0c0edf2b3d694e50f", size = 9523, upload-time = "2023-08-23T19:24:35.233Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/bb/243d10169c8397051bad6bdd10beb2407fa490bfe01216f5fad09e066191/boxfs-0.2.1-py3-none-any.whl", hash = "sha256:ae796c30309bd5a02654fff9eddf1ed320356225568fad0e109e1942beaef72a", size = 9358 }, + { url = "https://files.pythonhosted.org/packages/86/bb/243d10169c8397051bad6bdd10beb2407fa490bfe01216f5fad09e066191/boxfs-0.2.1-py3-none-any.whl", hash = "sha256:ae796c30309bd5a02654fff9eddf1ed320356225568fad0e109e1942beaef72a", size = 9358, upload-time = "2023-08-23T19:24:34.066Z" }, ] [[package]] @@ -571,9 +571,9 @@ dependencies = [ { name = "requests-toolbelt" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/d7/c1a95bb602d7f90a85a68d8e6f11954e50c255110d39e2167c7796252622/boxsdk-3.14.0.tar.gz", hash = "sha256:7918b1929368724662474fffa417fa0457a523d089b8185260efbedd28c4f9b1", size = 232630 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/d7/c1a95bb602d7f90a85a68d8e6f11954e50c255110d39e2167c7796252622/boxsdk-3.14.0.tar.gz", hash = "sha256:7918b1929368724662474fffa417fa0457a523d089b8185260efbedd28c4f9b1", size = 232630, upload-time = "2025-04-09T15:07:15.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/5d/4e15511e0f4f2f9fbbf4646a8d0e138e5c53a3d428f1724e7dc3c8acf556/boxsdk-3.14.0-py2.py3-none-any.whl", hash = "sha256:0314e2f172b050e98489955f2e9001263de79c3dd751e6feee19f2195fdf7c01", size = 141329 }, + { url = "https://files.pythonhosted.org/packages/4d/5d/4e15511e0f4f2f9fbbf4646a8d0e138e5c53a3d428f1724e7dc3c8acf556/boxsdk-3.14.0-py2.py3-none-any.whl", hash = "sha256:0314e2f172b050e98489955f2e9001263de79c3dd751e6feee19f2195fdf7c01", size = 141329, upload-time = "2025-04-09T15:07:13.295Z" }, ] [package.optional-dependencies] @@ -596,18 +596,18 @@ dependencies = [ { name = "python-dateutil" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144, upload-time = "2025-06-01T11:08:12.563Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775 }, + { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775, upload-time = "2025-06-01T11:08:09.94Z" }, ] [[package]] name = "certifi" version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -617,111 +617,111 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } -wheels = [ - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +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/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]] name = "charset-normalizer" version = "3.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] @@ -731,9 +731,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[package]] @@ -743,9 +743,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089 } +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631 }, + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, ] [[package]] @@ -755,9 +755,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051 }, + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, ] [[package]] @@ -768,102 +768,102 @@ dependencies = [ { name = "click" }, { name = "prompt-toolkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449 } +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289 }, + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" version = "7.13.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449 }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810 }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308 }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052 }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165 }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432 }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716 }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089 }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232 }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299 }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796 }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673 }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990 }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800 }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415 }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474 }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844 }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832 }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434 }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676 }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807 }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058 }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805 }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766 }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923 }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591 }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364 }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010 }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818 }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438 }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165 }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516 }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804 }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885 }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308 }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452 }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057 }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875 }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500 }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212 }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398 }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584 }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688 }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746 }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003 }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522 }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855 }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887 }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396 }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745 }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055 }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911 }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754 }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720 }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994 }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531 }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189 }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258 }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073 }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638 }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246 }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514 }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877 }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004 }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408 }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544 }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980 }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871 }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472 }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210 }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319 }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638 }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040 }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148 }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172 }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242 }, +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [[package]] @@ -873,9 +873,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762 } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422 }, + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, ] [[package]] @@ -885,50 +885,50 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324 }, - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243 }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235 }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323 }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085 }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137 }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867 }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805 }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461 }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488 }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256 }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117 }, - { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154 }, - { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138 }, - { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830 }, - { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201 }, - { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822 }, - { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875 }, - { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385 }, - { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082 }, - { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328 }, - { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530 }, - { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046 }, - { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660 }, - { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229 }, - { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364 }, - { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498 }, - { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790 }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408 }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196 }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782 }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618 }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970 }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873 }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804 }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217 }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252 }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388 }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186 }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539 }, - { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307 }, - { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408 }, +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, ] [[package]] @@ -939,48 +939,48 @@ dependencies = [ { name = "marshmallow" }, { name = "typing-inspect" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227 } +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 }, + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, ] [[package]] name = "debugpy" version = "1.8.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686 }, - { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588 }, - { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372 }, - { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835 }, - { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560 }, - { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272 }, - { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208 }, - { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930 }, - { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066 }, - { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425 }, - { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407 }, - { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521 }, - { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658 }, + { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, + { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, + { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" }, + { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" }, + { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" }, + { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, ] [[package]] name = "decorator" version = "5.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] [[package]] name = "defusedxml" version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] [[package]] @@ -990,9 +990,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744 } +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998 }, + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, ] [[package]] @@ -1002,27 +1002,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178 }, + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] [[package]] name = "dirtyjson" version = "1.0.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/04/d24f6e645ad82ba0ef092fa17d9ef7a21953781663648a01c9371d9e8e98/dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd", size = 30782 } +sdist = { url = "https://files.pythonhosted.org/packages/db/04/d24f6e645ad82ba0ef092fa17d9ef7a21953781663648a01c9371d9e8e98/dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd", size = 30782, upload-time = "2022-11-28T23:32:33.319Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197 }, + { url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197, upload-time = "2022-11-28T23:32:31.219Z" }, ] [[package]] name = "distro" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] @@ -1036,9 +1036,9 @@ dependencies = [ { name = "urllib3" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/73/f7c9a14e88e769f38cb7fb45aa88dfd795faa8e18aea11bababf6e068d5e/docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20", size = 259301 } +sdist = { url = "https://files.pythonhosted.org/packages/f0/73/f7c9a14e88e769f38cb7fb45aa88dfd795faa8e18aea11bababf6e068d5e/docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20", size = 259301, upload-time = "2023-06-01T14:24:49.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/be/3032490fa33b36ddc8c4b1da3252c6f974e7133f1a50de00c6b85cca203a/docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9", size = 148096 }, + { url = "https://files.pythonhosted.org/packages/db/be/3032490fa33b36ddc8c4b1da3252c6f974e7133f1a50de00c6b85cca203a/docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9", size = 148096, upload-time = "2023-06-01T14:24:47.769Z" }, ] [[package]] @@ -1050,9 +1050,9 @@ dependencies = [ { name = "six" }, { name = "stone" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/56/ac085f58e8e0d0bcafdf98c2605e454ac946e3d0c72679669ae112dc30be/dropbox-12.0.2.tar.gz", hash = "sha256:50057fd5ad5fcf047f542dfc6747a896e7ef982f1b5f8500daf51f3abd609962", size = 560236 } +sdist = { url = "https://files.pythonhosted.org/packages/9e/56/ac085f58e8e0d0bcafdf98c2605e454ac946e3d0c72679669ae112dc30be/dropbox-12.0.2.tar.gz", hash = "sha256:50057fd5ad5fcf047f542dfc6747a896e7ef982f1b5f8500daf51f3abd609962", size = 560236, upload-time = "2024-06-03T16:45:30.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/de/95d8204d9a20fbdb353c5f8e4229b0fcb90f22b96f8246ff1f47c8a45fd5/dropbox-12.0.2-py3-none-any.whl", hash = "sha256:c5b7e9c2668adb6b12dcecd84342565dc50f7d35ab6a748d155cb79040979d1c", size = 572076 }, + { url = "https://files.pythonhosted.org/packages/2d/de/95d8204d9a20fbdb353c5f8e4229b0fcb90f22b96f8246ff1f47c8a45fd5/dropbox-12.0.2-py3-none-any.whl", hash = "sha256:c5b7e9c2668adb6b12dcecd84342565dc50f7d35ab6a748d155cb79040979d1c", size = 572076, upload-time = "2024-06-03T16:45:28.153Z" }, ] [[package]] @@ -1064,7 +1064,7 @@ dependencies = [ { name = "fsspec" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/15/6d8f4c3033ad2bc364b8bb613c52c96653f2268f32ecff4f3ab5f1d7c19b/dropboxdrivefs-1.4.1.tar.gz", hash = "sha256:6f3c6061d045813553ce91ed0e2b682f1d70bec74011943c92b3181faacefd34", size = 7413 } +sdist = { url = "https://files.pythonhosted.org/packages/97/15/6d8f4c3033ad2bc364b8bb613c52c96653f2268f32ecff4f3ab5f1d7c19b/dropboxdrivefs-1.4.1.tar.gz", hash = "sha256:6f3c6061d045813553ce91ed0e2b682f1d70bec74011943c92b3181faacefd34", size = 7413, upload-time = "2024-05-27T14:04:37.648Z" } [[package]] name = "factory-boy" @@ -1073,9 +1073,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "faker" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036 }, + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, ] [[package]] @@ -1085,68 +1085,68 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/2a/96fff3edcb10f6505143448a4b91535f77b74865cec45be52690ee280443/faker-40.5.1.tar.gz", hash = "sha256:70222361cd82aa10cb86066d1a4e8f47f2bcdc919615c412045a69c4e6da0cd3", size = 1952684 } +sdist = { url = "https://files.pythonhosted.org/packages/03/2a/96fff3edcb10f6505143448a4b91535f77b74865cec45be52690ee280443/faker-40.5.1.tar.gz", hash = "sha256:70222361cd82aa10cb86066d1a4e8f47f2bcdc919615c412045a69c4e6da0cd3", size = 1952684, upload-time = "2026-02-23T21:34:38.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/a9/1eed4db92d0aec2f9bfdf1faae0ab0418b5e121dda5701f118a7a4f0cd6a/faker-40.5.1-py3-none-any.whl", hash = "sha256:c69640c1e13bad49b4bcebcbf1b52f9f1a872b6ea186c248ada34d798f1661bf", size = 1987053 }, + { url = "https://files.pythonhosted.org/packages/4d/a9/1eed4db92d0aec2f9bfdf1faae0ab0418b5e121dda5701f118a7a4f0cd6a/faker-40.5.1-py3-none-any.whl", hash = "sha256:c69640c1e13bad49b4bcebcbf1b52f9f1a872b6ea186c248ada34d798f1661bf", size = 1987053, upload-time = "2026-02-23T21:34:36.418Z" }, ] [[package]] name = "fastuuid" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164 }, - { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837 }, - { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370 }, - { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766 }, - { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105 }, - { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564 }, - { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659 }, - { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430 }, - { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894 }, - { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374 }, - { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550 }, - { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720 }, - { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024 }, - { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679 }, - { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862 }, - { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278 }, - { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788 }, - { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819 }, - { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546 }, - { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921 }, - { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559 }, - { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539 }, - { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600 }, - { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069 }, - { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543 }, - { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798 }, - { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283 }, - { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627 }, - { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778 }, - { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605 }, - { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837 }, - { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532 }, - { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457 }, +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, ] [[package]] name = "filelock" version = "3.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158 } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427 }, + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, ] [[package]] name = "filetype" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970 }, + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, ] [[package]] @@ -1158,107 +1158,107 @@ dependencies = [ { name = "pycodestyle" }, { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326 } +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922 }, + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] [[package]] name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127 }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698 }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749 }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298 }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015 }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038 }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130 }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845 }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131 }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542 }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308 }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210 }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972 }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536 }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330 }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627 }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238 }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738 }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739 }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186 }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196 }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830 }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289 }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318 }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814 }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762 }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470 }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042 }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148 }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676 }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451 }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507 }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "fsspec" version = "2024.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a0/52/f16a068ebadae42526484c31f4398e62962504e5724a8ba5dc3409483df2/fsspec-2024.10.0.tar.gz", hash = "sha256:eda2d8a4116d4f2429db8550f2457da57279247dd930bb12f821b58391359493", size = 286853 } +sdist = { url = "https://files.pythonhosted.org/packages/a0/52/f16a068ebadae42526484c31f4398e62962504e5724a8ba5dc3409483df2/fsspec-2024.10.0.tar.gz", hash = "sha256:eda2d8a4116d4f2429db8550f2457da57279247dd930bb12f821b58391359493", size = 286853, upload-time = "2024-10-21T01:21:16.969Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b2/454d6e7f0158951d8a78c2e1eb4f69ae81beb8dca5fee9809c6c99e9d0d0/fsspec-2024.10.0-py3-none-any.whl", hash = "sha256:03b9a6785766a4de40368b88906366755e2819e758b83705c88cd7cb5fe81871", size = 179641 }, + { url = "https://files.pythonhosted.org/packages/c6/b2/454d6e7f0158951d8a78c2e1eb4f69ae81beb8dca5fee9809c6c99e9d0d0/fsspec-2024.10.0-py3-none-any.whl", hash = "sha256:03b9a6785766a4de40368b88906366755e2819e758b83705c88cd7cb5fe81871", size = 179641, upload-time = "2024-10-21T01:21:14.793Z" }, ] [package.optional-dependencies] @@ -1270,9 +1270,9 @@ sftp = [ name = "funcy" version = "2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/b8/c6081521ff70afdff55cd9512b2220bbf4fa88804dae51d1b57b4b58ef32/funcy-2.0.tar.gz", hash = "sha256:3963315d59d41c6f30c04bc910e10ab50a3ac4a225868bfa96feed133df075cb", size = 537931 } +sdist = { url = "https://files.pythonhosted.org/packages/70/b8/c6081521ff70afdff55cd9512b2220bbf4fa88804dae51d1b57b4b58ef32/funcy-2.0.tar.gz", hash = "sha256:3963315d59d41c6f30c04bc910e10ab50a3ac4a225868bfa96feed133df075cb", size = 537931, upload-time = "2023-03-28T06:22:46.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl", hash = "sha256:53df23c8bb1651b12f095df764bfb057935d49537a56de211b098f4c79614bb0", size = 30891 }, + { url = "https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl", hash = "sha256:53df23c8bb1651b12f095df764bfb057935d49537a56de211b098f4c79614bb0", size = 30891, upload-time = "2023-03-28T06:22:42.576Z" }, ] [[package]] @@ -1288,9 +1288,9 @@ dependencies = [ { name = "google-cloud-storage" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/1e/1d8c4593d9e2eb04918fec43253ab152823d67ad51ad9e3ab6b3a78c431a/gcsfs-2024.10.0.tar.gz", hash = "sha256:5df54cfe568e8fdeea5aafa7fed695cdc69a9a674e991ca8c1ce634f5df1d314", size = 79588 } +sdist = { url = "https://files.pythonhosted.org/packages/e5/1e/1d8c4593d9e2eb04918fec43253ab152823d67ad51ad9e3ab6b3a78c431a/gcsfs-2024.10.0.tar.gz", hash = "sha256:5df54cfe568e8fdeea5aafa7fed695cdc69a9a674e991ca8c1ce634f5df1d314", size = 79588, upload-time = "2024-10-21T13:43:26.163Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/96/d60e835fb7d10166c77aef0c1fa30e634153c03a0f486786977b95f88fde/gcsfs-2024.10.0-py2.py3-none-any.whl", hash = "sha256:bb2d23547e61203ea2dda5fa6c4b91a0c34b74ebe8bb6ab1926f6c33381bceb2", size = 34953 }, + { url = "https://files.pythonhosted.org/packages/dc/96/d60e835fb7d10166c77aef0c1fa30e634153c03a0f486786977b95f88fde/gcsfs-2024.10.0-py2.py3-none-any.whl", hash = "sha256:bb2d23547e61203ea2dda5fa6c4b91a0c34b74ebe8bb6ab1926f6c33381bceb2", size = 34953, upload-time = "2024-10-21T13:43:24.951Z" }, ] [[package]] @@ -1304,9 +1304,9 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266 } +sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489 }, + { url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489, upload-time = "2025-10-03T00:07:32.924Z" }, ] [package.optional-dependencies] @@ -1326,9 +1326,9 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/4ab3e3516b93bb50ed7814738ea61d49cba3f72f4e331dc9518ae2731e92/google_api_python_client-2.190.0.tar.gz", hash = "sha256:5357f34552e3724d80d2604c8fa146766e0a9d6bb0afada886fafed9feafeef6", size = 14111143 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/4ab3e3516b93bb50ed7814738ea61d49cba3f72f4e331dc9518ae2731e92/google_api_python_client-2.190.0.tar.gz", hash = "sha256:5357f34552e3724d80d2604c8fa146766e0a9d6bb0afada886fafed9feafeef6", size = 14111143, upload-time = "2026-02-12T00:38:03.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/ad/223d5f4b0b987669ffeb3eadd7e9f85ece633aa7fd3246f1e2f6238e1e05/google_api_python_client-2.190.0-py3-none-any.whl", hash = "sha256:d9b5266758f96c39b8c21d9bbfeb4e58c14dbfba3c931f7c5a8d7fdcd292dd57", size = 14682070 }, + { url = "https://files.pythonhosted.org/packages/07/ad/223d5f4b0b987669ffeb3eadd7e9f85ece633aa7fd3246f1e2f6238e1e05/google_api_python_client-2.190.0-py3-none-any.whl", hash = "sha256:d9b5266758f96c39b8c21d9bbfeb4e58c14dbfba3c931f7c5a8d7fdcd292dd57", size = 14682070, upload-time = "2026-02-12T00:38:00.974Z" }, ] [[package]] @@ -1339,9 +1339,9 @@ dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379 } +sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400 }, + { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400, upload-time = "2026-06-15T22:33:14.992Z" }, ] [[package]] @@ -1352,9 +1352,9 @@ dependencies = [ { name = "google-auth" }, { name = "httplib2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134 } +sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529 }, + { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" }, ] [[package]] @@ -1365,9 +1365,9 @@ dependencies = [ { name = "google-auth" }, { name = "requests-oauthlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/b4/1b19567e4c567b796f5c593d89895f3cfae5a38e04f27c6af87618fd0942/google_auth_oauthlib-1.3.0.tar.gz", hash = "sha256:cd39e807ac7229d6b8b9c1e297321d36fcc8a9e4857dff4301870985df51a528", size = 21777 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/b4/1b19567e4c567b796f5c593d89895f3cfae5a38e04f27c6af87618fd0942/google_auth_oauthlib-1.3.0.tar.gz", hash = "sha256:cd39e807ac7229d6b8b9c1e297321d36fcc8a9e4857dff4301870985df51a528", size = 21777, upload-time = "2026-02-27T14:13:01.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/56/909fd5632226d3fba31d7aeffd4754410735d49362f5809956fe3e9af344/google_auth_oauthlib-1.3.0-py3-none-any.whl", hash = "sha256:386b3fb85cf4a5b819c6ad23e3128d975216b4cac76324de1d90b128aaf38f29", size = 19308 }, + { url = "https://files.pythonhosted.org/packages/2f/56/909fd5632226d3fba31d7aeffd4754410735d49362f5809956fe3e9af344/google_auth_oauthlib-1.3.0-py3-none-any.whl", hash = "sha256:386b3fb85cf4a5b819c6ad23e3128d975216b4cac76324de1d90b128aaf38f29", size = 19308, upload-time = "2026-02-27T14:12:47.865Z" }, ] [[package]] @@ -1385,9 +1385,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/ff/2c520952db184dec31e2ee988cfa37fa9e7776935a3f2eccc44252ecab5f/google-cloud-bigquery-3.11.4.tar.gz", hash = "sha256:697df117241a2283bcbb93b21e10badc14e51c9a90800d2a7e1a3e1c7d842974", size = 410777 } +sdist = { url = "https://files.pythonhosted.org/packages/62/ff/2c520952db184dec31e2ee988cfa37fa9e7776935a3f2eccc44252ecab5f/google-cloud-bigquery-3.11.4.tar.gz", hash = "sha256:697df117241a2283bcbb93b21e10badc14e51c9a90800d2a7e1a3e1c7d842974", size = 410777, upload-time = "2023-07-19T23:12:12.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/6a/d0ef792288f2fa2cfea80899a82de302b3332dfda41984fe114e2cfbf700/google_cloud_bigquery-3.11.4-py2.py3-none-any.whl", hash = "sha256:5fa7897743a0ed949ade25a0942fc9e7557d8fce307c6f8a76d1b604cf27f1b1", size = 219607 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/d0ef792288f2fa2cfea80899a82de302b3332dfda41984fe114e2cfbf700/google_cloud_bigquery-3.11.4-py2.py3-none-any.whl", hash = "sha256:5fa7897743a0ed949ade25a0942fc9e7557d8fce307c6f8a76d1b604cf27f1b1", size = 219607, upload-time = "2023-07-19T23:12:09.449Z" }, ] [[package]] @@ -1398,9 +1398,9 @@ dependencies = [ { name = "google-api-core" }, { name = "google-auth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027 } +sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469 }, + { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, ] [[package]] @@ -1413,9 +1413,9 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/6b/92b705f408c1d928526b65d1259be4254ef1f45e620f01f8665156b4d781/google-cloud-secret-manager-2.16.1.tar.gz", hash = "sha256:149d11ce9be7ea81d4ac3544d3fcd4c716a9edb2cb775d9c075231570b079fbb", size = 128884 } +sdist = { url = "https://files.pythonhosted.org/packages/48/6b/92b705f408c1d928526b65d1259be4254ef1f45e620f01f8665156b4d781/google-cloud-secret-manager-2.16.1.tar.gz", hash = "sha256:149d11ce9be7ea81d4ac3544d3fcd4c716a9edb2cb775d9c075231570b079fbb", size = 128884, upload-time = "2023-03-27T14:51:09.684Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/e3/c3aade516eaf544bd7d86694178de9c2da8eff8fc40326d0265acc65991d/google_cloud_secret_manager-2.16.1-py2.py3-none-any.whl", hash = "sha256:dad28c24921fb62961aafe808be0e7935a99096f03ac29eeeefa04b85534c1f3", size = 116749 }, + { url = "https://files.pythonhosted.org/packages/6c/e3/c3aade516eaf544bd7d86694178de9c2da8eff8fc40326d0265acc65991d/google_cloud_secret_manager-2.16.1-py2.py3-none-any.whl", hash = "sha256:dad28c24921fb62961aafe808be0e7935a99096f03ac29eeeefa04b85534c1f3", size = 116749, upload-time = "2023-03-27T14:51:07.661Z" }, ] [[package]] @@ -1429,32 +1429,32 @@ dependencies = [ { name = "google-resumable-media" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/50/c9998f84fd8ce8799d7f8020466bbc5c9e3b1126b04a09fdb02378d451b0/google-cloud-storage-2.9.0.tar.gz", hash = "sha256:9b6ae7b509fc294bdacb84d0f3ea8e20e2c54a8b4bbe39c5707635fec214eff3", size = 5498811 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/50/c9998f84fd8ce8799d7f8020466bbc5c9e3b1126b04a09fdb02378d451b0/google-cloud-storage-2.9.0.tar.gz", hash = "sha256:9b6ae7b509fc294bdacb84d0f3ea8e20e2c54a8b4bbe39c5707635fec214eff3", size = 5498811, upload-time = "2023-05-04T17:56:46.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/fb/3770e7f44cf6133f502e1b8503b6739351b53272cf8313b47f1de6cf4960/google_cloud_storage-2.9.0-py2.py3-none-any.whl", hash = "sha256:83a90447f23d5edd045e0037982c270302e3aeb45fc1288d2c2ca713d27bad94", size = 113512 }, + { url = "https://files.pythonhosted.org/packages/74/fb/3770e7f44cf6133f502e1b8503b6739351b53272cf8313b47f1de6cf4960/google_cloud_storage-2.9.0-py2.py3-none-any.whl", hash = "sha256:83a90447f23d5edd045e0037982c270302e3aeb45fc1288d2c2ca713d27bad94", size = 113512, upload-time = "2023-05-04T17:56:43.929Z" }, ] [[package]] name = "google-crc32c" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192 } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300 }, - { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867 }, - { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364 }, - { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740 }, - { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437 }, - { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297 }, - { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867 }, - { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344 }, - { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694 }, - { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435 }, - { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301 }, - { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868 }, - { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381 }, - { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734 }, - { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878 }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, ] [[package]] @@ -1464,9 +1464,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-crc32c" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265 } +sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265, upload-time = "2025-11-17T15:38:06.659Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340 }, + { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340, upload-time = "2025-11-17T15:38:05.594Z" }, ] [[package]] @@ -1476,9 +1476,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433 } +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515 }, + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, ] [package.optional-dependencies] @@ -1490,43 +1490,43 @@ grpc = [ name = "greenlet" version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358 }, - { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217 }, - { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792 }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250 }, - { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875 }, - { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467 }, - { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001 }, - { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081 }, - { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331 }, - { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120 }, - { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238 }, - { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219 }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268 }, - { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774 }, - { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277 }, - { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455 }, - { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961 }, - { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221 }, - { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650 }, - { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295 }, - { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163 }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371 }, - { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160 }, - { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181 }, - { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713 }, - { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034 }, - { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437 }, - { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617 }, - { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189 }, - { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225 }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581 }, - { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907 }, - { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857 }, - { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010 }, - { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086 }, +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] [[package]] @@ -1537,9 +1537,9 @@ dependencies = [ { name = "griffecli" }, { name = "griffelib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/56/28a0accac339c164b52a92c6cfc45a903acc0c174caa5c1713803467b533/griffe-2.0.0.tar.gz", hash = "sha256:c68979cd8395422083a51ea7cf02f9c119d889646d99b7b656ee43725de1b80f", size = 293906 } +sdist = { url = "https://files.pythonhosted.org/packages/04/56/28a0accac339c164b52a92c6cfc45a903acc0c174caa5c1713803467b533/griffe-2.0.0.tar.gz", hash = "sha256:c68979cd8395422083a51ea7cf02f9c119d889646d99b7b656ee43725de1b80f", size = 293906, upload-time = "2026-03-23T21:06:53.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214 }, + { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" }, ] [[package]] @@ -1550,18 +1550,18 @@ dependencies = [ { name = "colorama" }, { name = "griffelib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/f8/2e129fd4a86e52e58eefe664de05e7d502decf766e7316cc9e70fdec3e18/griffecli-2.0.0.tar.gz", hash = "sha256:312fa5ebb4ce6afc786356e2d0ce85b06c1c20d45abc42d74f0cda65e159f6ef", size = 56213 } +sdist = { url = "https://files.pythonhosted.org/packages/a4/f8/2e129fd4a86e52e58eefe664de05e7d502decf766e7316cc9e70fdec3e18/griffecli-2.0.0.tar.gz", hash = "sha256:312fa5ebb4ce6afc786356e2d0ce85b06c1c20d45abc42d74f0cda65e159f6ef", size = 56213, upload-time = "2026-03-23T21:06:54.8Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345 }, + { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" }, ] [[package]] name = "griffelib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312 } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004 }, + { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] [[package]] @@ -1573,9 +1573,9 @@ dependencies = [ { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745 } +sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690 }, + { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, ] [[package]] @@ -1585,38 +1585,38 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985 }, - { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853 }, - { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766 }, - { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027 }, - { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766 }, - { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161 }, - { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303 }, - { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222 }, - { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123 }, - { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657 }, - { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143 }, - { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926 }, - { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628 }, - { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574 }, - { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639 }, - { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838 }, - { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878 }, - { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412 }, - { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899 }, - { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393 }, - { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591 }, - { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685 }, - { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803 }, - { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206 }, - { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826 }, - { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897 }, - { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404 }, - { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837 }, - { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439 }, - { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852 }, +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, ] [[package]] @@ -1628,9 +1628,9 @@ dependencies = [ { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/d7/013ef01c5a1c2fd0932c27c904934162f69f41ca0f28396d3ffe4d386123/grpcio-status-1.62.3.tar.gz", hash = "sha256:289bdd7b2459794a12cf95dc0cb727bd4a1742c37bd823f760236c937e53a485", size = 13063 } +sdist = { url = "https://files.pythonhosted.org/packages/7c/d7/013ef01c5a1c2fd0932c27c904934162f69f41ca0f28396d3ffe4d386123/grpcio-status-1.62.3.tar.gz", hash = "sha256:289bdd7b2459794a12cf95dc0cb727bd4a1742c37bd823f760236c937e53a485", size = 13063, upload-time = "2024-08-06T00:37:08.003Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/40/972271de05f9315c0d69f9f7ebbcadd83bc85322f538637d11bb8c67803d/grpcio_status-1.62.3-py3-none-any.whl", hash = "sha256:f9049b762ba8de6b1086789d8315846e094edac2c50beaf462338b301a8fd4b8", size = 14448 }, + { url = "https://files.pythonhosted.org/packages/90/40/972271de05f9315c0d69f9f7ebbcadd83bc85322f538637d11bb8c67803d/grpcio_status-1.62.3-py3-none-any.whl", hash = "sha256:f9049b762ba8de6b1086789d8315846e094edac2c50beaf462338b301a8fd4b8", size = 14448, upload-time = "2024-08-06T00:30:15.702Z" }, ] [[package]] @@ -1642,25 +1642,25 @@ dependencies = [ { name = "protobuf" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/fa/b69bd8040eafc09b88bb0ec0fea59e8aacd1a801e688af087cead213b0d0/grpcio-tools-1.62.3.tar.gz", hash = "sha256:7c7136015c3d62c3eef493efabaf9e3380e3e66d24ee8e94c01cb71377f57833", size = 4538520 } +sdist = { url = "https://files.pythonhosted.org/packages/54/fa/b69bd8040eafc09b88bb0ec0fea59e8aacd1a801e688af087cead213b0d0/grpcio-tools-1.62.3.tar.gz", hash = "sha256:7c7136015c3d62c3eef493efabaf9e3380e3e66d24ee8e94c01cb71377f57833", size = 4538520, upload-time = "2024-08-06T00:37:11.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/a5/d6887eba415ce318ae5005e8dfac3fa74892400b54b6d37b79e8b4f14f5e/grpcio_tools-1.62.3-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:d102b9b21c4e1e40af9a2ab3c6d41afba6bd29c0aa50ca013bf85c99cdc44ac5", size = 5147690 }, - { url = "https://files.pythonhosted.org/packages/8a/7c/3cde447a045e83ceb4b570af8afe67ffc86896a2fe7f59594dc8e5d0a645/grpcio_tools-1.62.3-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:0a52cc9444df978438b8d2332c0ca99000521895229934a59f94f37ed896b133", size = 2720538 }, - { url = "https://files.pythonhosted.org/packages/88/07/f83f2750d44ac4f06c07c37395b9c1383ef5c994745f73c6bfaf767f0944/grpcio_tools-1.62.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141d028bf5762d4a97f981c501da873589df3f7e02f4c1260e1921e565b376fa", size = 3071571 }, - { url = "https://files.pythonhosted.org/packages/37/74/40175897deb61e54aca716bc2e8919155b48f33aafec8043dda9592d8768/grpcio_tools-1.62.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47a5c093ab256dec5714a7a345f8cc89315cb57c298b276fa244f37a0ba507f0", size = 2806207 }, - { url = "https://files.pythonhosted.org/packages/ec/ee/d8de915105a217cbcb9084d684abdc032030dcd887277f2ef167372287fe/grpcio_tools-1.62.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f6831fdec2b853c9daa3358535c55eed3694325889aa714070528cf8f92d7d6d", size = 3685815 }, - { url = "https://files.pythonhosted.org/packages/fd/d9/4360a6c12be3d7521b0b8c39e5d3801d622fbb81cc2721dbd3eee31e28c8/grpcio_tools-1.62.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e02d7c1a02e3814c94ba0cfe43d93e872c758bd8fd5c2797f894d0c49b4a1dfc", size = 3298378 }, - { url = "https://files.pythonhosted.org/packages/29/3b/7cdf4a9e5a3e0a35a528b48b111355cd14da601413a4f887aa99b6da468f/grpcio_tools-1.62.3-cp312-cp312-win32.whl", hash = "sha256:b881fd9505a84457e9f7e99362eeedd86497b659030cf57c6f0070df6d9c2b9b", size = 910416 }, - { url = "https://files.pythonhosted.org/packages/6c/66/dd3ec249e44c1cc15e902e783747819ed41ead1336fcba72bf841f72c6e9/grpcio_tools-1.62.3-cp312-cp312-win_amd64.whl", hash = "sha256:11c625eebefd1fd40a228fc8bae385e448c7e32a6ae134e43cf13bbc23f902b7", size = 1052856 }, + { url = "https://files.pythonhosted.org/packages/2a/a5/d6887eba415ce318ae5005e8dfac3fa74892400b54b6d37b79e8b4f14f5e/grpcio_tools-1.62.3-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:d102b9b21c4e1e40af9a2ab3c6d41afba6bd29c0aa50ca013bf85c99cdc44ac5", size = 5147690, upload-time = "2024-08-06T00:31:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7c/3cde447a045e83ceb4b570af8afe67ffc86896a2fe7f59594dc8e5d0a645/grpcio_tools-1.62.3-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:0a52cc9444df978438b8d2332c0ca99000521895229934a59f94f37ed896b133", size = 2720538, upload-time = "2024-08-06T00:31:18.905Z" }, + { url = "https://files.pythonhosted.org/packages/88/07/f83f2750d44ac4f06c07c37395b9c1383ef5c994745f73c6bfaf767f0944/grpcio_tools-1.62.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141d028bf5762d4a97f981c501da873589df3f7e02f4c1260e1921e565b376fa", size = 3071571, upload-time = "2024-08-06T00:31:21.684Z" }, + { url = "https://files.pythonhosted.org/packages/37/74/40175897deb61e54aca716bc2e8919155b48f33aafec8043dda9592d8768/grpcio_tools-1.62.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47a5c093ab256dec5714a7a345f8cc89315cb57c298b276fa244f37a0ba507f0", size = 2806207, upload-time = "2024-08-06T00:31:24.208Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/d8de915105a217cbcb9084d684abdc032030dcd887277f2ef167372287fe/grpcio_tools-1.62.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f6831fdec2b853c9daa3358535c55eed3694325889aa714070528cf8f92d7d6d", size = 3685815, upload-time = "2024-08-06T00:31:26.917Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/4360a6c12be3d7521b0b8c39e5d3801d622fbb81cc2721dbd3eee31e28c8/grpcio_tools-1.62.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e02d7c1a02e3814c94ba0cfe43d93e872c758bd8fd5c2797f894d0c49b4a1dfc", size = 3298378, upload-time = "2024-08-06T00:31:30.401Z" }, + { url = "https://files.pythonhosted.org/packages/29/3b/7cdf4a9e5a3e0a35a528b48b111355cd14da601413a4f887aa99b6da468f/grpcio_tools-1.62.3-cp312-cp312-win32.whl", hash = "sha256:b881fd9505a84457e9f7e99362eeedd86497b659030cf57c6f0070df6d9c2b9b", size = 910416, upload-time = "2024-08-06T00:31:33.118Z" }, + { url = "https://files.pythonhosted.org/packages/6c/66/dd3ec249e44c1cc15e902e783747819ed41ead1336fcba72bf841f72c6e9/grpcio_tools-1.62.3-cp312-cp312-win_amd64.whl", hash = "sha256:11c625eebefd1fd40a228fc8bae385e448c7e32a6ae134e43cf13bbc23f902b7", size = 1052856, upload-time = "2024-08-06T00:31:36.519Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -1671,50 +1671,50 @@ dependencies = [ { name = "hpack" }, { name = "hyperframe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026 } +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779 }, + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, ] [[package]] name = "hf-xet" version = "1.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/cb/9bb543bd987ffa1ee48202cc96a756951b734b79a542335c566148ade36c/hf_xet-1.3.2.tar.gz", hash = "sha256:e130ee08984783d12717444e538587fa2119385e5bd8fc2bb9f930419b73a7af", size = 643646 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/75/462285971954269432aad2e7938c5c7ff9ec7d60129cec542ab37121e3d6/hf_xet-1.3.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:335a8f36c55fd35a92d0062f4e9201b4015057e62747b7e7001ffb203c0ee1d2", size = 3761019 }, - { url = "https://files.pythonhosted.org/packages/35/56/987b0537ddaf88e17192ea09afa8eca853e55f39a4721578be436f8409df/hf_xet-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c1ae4d3a716afc774e66922f3cac8206bfa707db13f6a7e62dfff74bfc95c9a8", size = 3521565 }, - { url = "https://files.pythonhosted.org/packages/a8/5c/7e4a33a3d689f77761156cc34558047569e54af92e4d15a8f493229f6767/hf_xet-1.3.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6dbdf231efac0b9b39adcf12a07f0c030498f9212a18e8c50224d0e84ab803d", size = 4176494 }, - { url = "https://files.pythonhosted.org/packages/6b/b3/71e856bf9d9a69b3931837e8bf22e095775f268c8edcd4a9e8c355f92484/hf_xet-1.3.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c1980abfb68ecf6c1c7983379ed7b1e2b49a1aaf1a5aca9acc7d48e5e2e0a961", size = 3955601 }, - { url = "https://files.pythonhosted.org/packages/63/d7/aecf97b3f0a981600a67ff4db15e2d433389d698a284bb0ea5d8fcdd6f7f/hf_xet-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1c88fbd90ad0d27c46b77a445f0a436ebaa94e14965c581123b68b1c52f5fd30", size = 4154770 }, - { url = "https://files.pythonhosted.org/packages/e2/e1/3af961f71a40e09bf5ee909842127b6b00f5ab4ee3817599dc0771b79893/hf_xet-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:35b855024ca37f2dd113ac1c08993e997fbe167b9d61f9ef66d3d4f84015e508", size = 4394161 }, - { url = "https://files.pythonhosted.org/packages/a1/c3/859509bade9178e21b8b1db867b8e10e9f817ab9ac1de77cb9f461ced765/hf_xet-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:31612ba0629046e425ba50375685a2586e11fb9144270ebabd75878c3eaf6378", size = 3637377 }, - { url = "https://files.pythonhosted.org/packages/05/7f/724cfbef4da92d577b71f68bf832961c8919f36c60d28d289a9fc9d024d4/hf_xet-1.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:433c77c9f4e132b562f37d66c9b22c05b5479f243a1f06a120c1c06ce8b1502a", size = 3497875 }, - { url = "https://files.pythonhosted.org/packages/ba/75/9d54c1ae1d05fb704f977eca1671747babf1957f19f38ae75c5933bc2dc1/hf_xet-1.3.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c34e2c7aefad15792d57067c1c89b2b02c1bbaeabd7f8456ae3d07b4bbaf4094", size = 3761076 }, - { url = "https://files.pythonhosted.org/packages/f2/8a/08a24b6c6f52b5d26848c16e4b6d790bb810d1bf62c3505bed179f7032d3/hf_xet-1.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4bc995d6c41992831f762096020dc14a65fdf3963f86ffed580b596d04de32e3", size = 3521745 }, - { url = "https://files.pythonhosted.org/packages/b5/db/a75cf400dd8a1a8acf226a12955ff6ee999f272dfc0505bafd8079a61267/hf_xet-1.3.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:959083c89dee30f7d6f890b36cdadda823386c4de63b1a30384a75bfd2ae995d", size = 4176301 }, - { url = "https://files.pythonhosted.org/packages/01/40/6c4c798ffdd83e740dd3925c4e47793b07442a9efa3bc3866ba141a82365/hf_xet-1.3.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cfa760888633b08c01b398d212ce7e8c0d7adac6c86e4b20dfb2397d8acd78ee", size = 3955437 }, - { url = "https://files.pythonhosted.org/packages/0c/09/9a3aa7c5f07d3e5cc57bb750d12a124ffa72c273a87164bd848f9ac5cc14/hf_xet-1.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3155a02e083aa21fd733a7485c7c36025e49d5975c8d6bda0453d224dd0b0ac4", size = 4154535 }, - { url = "https://files.pythonhosted.org/packages/ae/e0/831f7fa6d90cb47a230bc23284b502c700e1483bbe459437b3844cdc0776/hf_xet-1.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91b1dc03c31cbf733d35dc03df7c5353686233d86af045e716f1e0ea4a2673cf", size = 4393891 }, - { url = "https://files.pythonhosted.org/packages/ab/96/6ed472fdce7f8b70f5da6e3f05be76816a610063003bfd6d9cea0bbb58a3/hf_xet-1.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:211f30098512d95e85ad03ae63bd7dd2c4df476558a5095d09f9e38e78cbf674", size = 3637583 }, - { url = "https://files.pythonhosted.org/packages/8b/e8/a069edc4570b3f8e123c0b80fadc94530f3d7b01394e1fc1bb223339366c/hf_xet-1.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4a6817c41de7c48ed9270da0b02849347e089c5ece9a0e72ae4f4b3a57617f82", size = 3497977 }, - { url = "https://files.pythonhosted.org/packages/d8/28/dbb024e2e3907f6f3052847ca7d1a2f7a3972fafcd53ff79018977fcb3e4/hf_xet-1.3.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f93b7595f1d8fefddfede775c18b5c9256757824f7f6832930b49858483cd56f", size = 3763961 }, - { url = "https://files.pythonhosted.org/packages/e4/71/b99aed3823c9d1795e4865cf437d651097356a3f38c7d5877e4ac544b8e4/hf_xet-1.3.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a85d3d43743174393afe27835bde0cd146e652b5fcfdbcd624602daef2ef3259", size = 3526171 }, - { url = "https://files.pythonhosted.org/packages/9d/ca/907890ce6ef5598b5920514f255ed0a65f558f820515b18db75a51b2f878/hf_xet-1.3.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c2a054a97c44e136b1f7f5a78f12b3efffdf2eed3abc6746fc5ea4b39511633", size = 4180750 }, - { url = "https://files.pythonhosted.org/packages/8c/ad/bc7f41f87173d51d0bce497b171c4ee0cbde1eed2d7b4216db5d0ada9f50/hf_xet-1.3.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:06b724a361f670ae557836e57801b82c75b534812e351a87a2c739f77d1e0635", size = 3961035 }, - { url = "https://files.pythonhosted.org/packages/73/38/600f4dda40c4a33133404d9fe644f1d35ff2d9babb4d0435c646c63dd107/hf_xet-1.3.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:305f5489d7241a47e0458ef49334be02411d1d0f480846363c1c8084ed9916f7", size = 4161378 }, - { url = "https://files.pythonhosted.org/packages/00/b3/7bc1ff91d1ac18420b7ad1e169b618b27c00001b96310a89f8a9294fe509/hf_xet-1.3.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06cdbde243c85f39a63b28e9034321399c507bcd5e7befdd17ed2ccc06dfe14e", size = 4398020 }, - { url = "https://files.pythonhosted.org/packages/2b/0b/99bfd948a3ed3620ab709276df3ad3710dcea61976918cce8706502927af/hf_xet-1.3.2-cp37-abi3-win_amd64.whl", hash = "sha256:9298b47cce6037b7045ae41482e703c471ce36b52e73e49f71226d2e8e5685a1", size = 3641624 }, - { url = "https://files.pythonhosted.org/packages/cc/02/9a6e4ca1f3f73a164c0cd48e41b3cc56585dcc37e809250de443d673266f/hf_xet-1.3.2-cp37-abi3-win_arm64.whl", hash = "sha256:83d8ec273136171431833a6957e8f3af496bee227a0fe47c7b8b39c106d1749a", size = 3503976 }, +sdist = { url = "https://files.pythonhosted.org/packages/8b/cb/9bb543bd987ffa1ee48202cc96a756951b734b79a542335c566148ade36c/hf_xet-1.3.2.tar.gz", hash = "sha256:e130ee08984783d12717444e538587fa2119385e5bd8fc2bb9f930419b73a7af", size = 643646, upload-time = "2026-02-27T17:26:08.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/75/462285971954269432aad2e7938c5c7ff9ec7d60129cec542ab37121e3d6/hf_xet-1.3.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:335a8f36c55fd35a92d0062f4e9201b4015057e62747b7e7001ffb203c0ee1d2", size = 3761019, upload-time = "2026-02-27T17:25:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/35/56/987b0537ddaf88e17192ea09afa8eca853e55f39a4721578be436f8409df/hf_xet-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c1ae4d3a716afc774e66922f3cac8206bfa707db13f6a7e62dfff74bfc95c9a8", size = 3521565, upload-time = "2026-02-27T17:25:47.469Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5c/7e4a33a3d689f77761156cc34558047569e54af92e4d15a8f493229f6767/hf_xet-1.3.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6dbdf231efac0b9b39adcf12a07f0c030498f9212a18e8c50224d0e84ab803d", size = 4176494, upload-time = "2026-02-27T17:25:40.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b3/71e856bf9d9a69b3931837e8bf22e095775f268c8edcd4a9e8c355f92484/hf_xet-1.3.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c1980abfb68ecf6c1c7983379ed7b1e2b49a1aaf1a5aca9acc7d48e5e2e0a961", size = 3955601, upload-time = "2026-02-27T17:25:38.376Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/aecf97b3f0a981600a67ff4db15e2d433389d698a284bb0ea5d8fcdd6f7f/hf_xet-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1c88fbd90ad0d27c46b77a445f0a436ebaa94e14965c581123b68b1c52f5fd30", size = 4154770, upload-time = "2026-02-27T17:25:56.756Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e1/3af961f71a40e09bf5ee909842127b6b00f5ab4ee3817599dc0771b79893/hf_xet-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:35b855024ca37f2dd113ac1c08993e997fbe167b9d61f9ef66d3d4f84015e508", size = 4394161, upload-time = "2026-02-27T17:25:58.111Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c3/859509bade9178e21b8b1db867b8e10e9f817ab9ac1de77cb9f461ced765/hf_xet-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:31612ba0629046e425ba50375685a2586e11fb9144270ebabd75878c3eaf6378", size = 3637377, upload-time = "2026-02-27T17:26:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/724cfbef4da92d577b71f68bf832961c8919f36c60d28d289a9fc9d024d4/hf_xet-1.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:433c77c9f4e132b562f37d66c9b22c05b5479f243a1f06a120c1c06ce8b1502a", size = 3497875, upload-time = "2026-02-27T17:26:09.034Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/9d54c1ae1d05fb704f977eca1671747babf1957f19f38ae75c5933bc2dc1/hf_xet-1.3.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c34e2c7aefad15792d57067c1c89b2b02c1bbaeabd7f8456ae3d07b4bbaf4094", size = 3761076, upload-time = "2026-02-27T17:25:55.42Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8a/08a24b6c6f52b5d26848c16e4b6d790bb810d1bf62c3505bed179f7032d3/hf_xet-1.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4bc995d6c41992831f762096020dc14a65fdf3963f86ffed580b596d04de32e3", size = 3521745, upload-time = "2026-02-27T17:25:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/b5/db/a75cf400dd8a1a8acf226a12955ff6ee999f272dfc0505bafd8079a61267/hf_xet-1.3.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:959083c89dee30f7d6f890b36cdadda823386c4de63b1a30384a75bfd2ae995d", size = 4176301, upload-time = "2026-02-27T17:25:46.044Z" }, + { url = "https://files.pythonhosted.org/packages/01/40/6c4c798ffdd83e740dd3925c4e47793b07442a9efa3bc3866ba141a82365/hf_xet-1.3.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cfa760888633b08c01b398d212ce7e8c0d7adac6c86e4b20dfb2397d8acd78ee", size = 3955437, upload-time = "2026-02-27T17:25:44.703Z" }, + { url = "https://files.pythonhosted.org/packages/0c/09/9a3aa7c5f07d3e5cc57bb750d12a124ffa72c273a87164bd848f9ac5cc14/hf_xet-1.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3155a02e083aa21fd733a7485c7c36025e49d5975c8d6bda0453d224dd0b0ac4", size = 4154535, upload-time = "2026-02-27T17:26:05.207Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e0/831f7fa6d90cb47a230bc23284b502c700e1483bbe459437b3844cdc0776/hf_xet-1.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91b1dc03c31cbf733d35dc03df7c5353686233d86af045e716f1e0ea4a2673cf", size = 4393891, upload-time = "2026-02-27T17:26:06.607Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/6ed472fdce7f8b70f5da6e3f05be76816a610063003bfd6d9cea0bbb58a3/hf_xet-1.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:211f30098512d95e85ad03ae63bd7dd2c4df476558a5095d09f9e38e78cbf674", size = 3637583, upload-time = "2026-02-27T17:26:17.349Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/a069edc4570b3f8e123c0b80fadc94530f3d7b01394e1fc1bb223339366c/hf_xet-1.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4a6817c41de7c48ed9270da0b02849347e089c5ece9a0e72ae4f4b3a57617f82", size = 3497977, upload-time = "2026-02-27T17:26:14.966Z" }, + { url = "https://files.pythonhosted.org/packages/d8/28/dbb024e2e3907f6f3052847ca7d1a2f7a3972fafcd53ff79018977fcb3e4/hf_xet-1.3.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f93b7595f1d8fefddfede775c18b5c9256757824f7f6832930b49858483cd56f", size = 3763961, upload-time = "2026-02-27T17:25:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/e4/71/b99aed3823c9d1795e4865cf437d651097356a3f38c7d5877e4ac544b8e4/hf_xet-1.3.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a85d3d43743174393afe27835bde0cd146e652b5fcfdbcd624602daef2ef3259", size = 3526171, upload-time = "2026-02-27T17:25:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/907890ce6ef5598b5920514f255ed0a65f558f820515b18db75a51b2f878/hf_xet-1.3.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c2a054a97c44e136b1f7f5a78f12b3efffdf2eed3abc6746fc5ea4b39511633", size = 4180750, upload-time = "2026-02-27T17:25:43.125Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ad/bc7f41f87173d51d0bce497b171c4ee0cbde1eed2d7b4216db5d0ada9f50/hf_xet-1.3.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:06b724a361f670ae557836e57801b82c75b534812e351a87a2c739f77d1e0635", size = 3961035, upload-time = "2026-02-27T17:25:41.837Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/600f4dda40c4a33133404d9fe644f1d35ff2d9babb4d0435c646c63dd107/hf_xet-1.3.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:305f5489d7241a47e0458ef49334be02411d1d0f480846363c1c8084ed9916f7", size = 4161378, upload-time = "2026-02-27T17:26:00.365Z" }, + { url = "https://files.pythonhosted.org/packages/00/b3/7bc1ff91d1ac18420b7ad1e169b618b27c00001b96310a89f8a9294fe509/hf_xet-1.3.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06cdbde243c85f39a63b28e9034321399c507bcd5e7befdd17ed2ccc06dfe14e", size = 4398020, upload-time = "2026-02-27T17:26:03.977Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/99bfd948a3ed3620ab709276df3ad3710dcea61976918cce8706502927af/hf_xet-1.3.2-cp37-abi3-win_amd64.whl", hash = "sha256:9298b47cce6037b7045ae41482e703c471ce36b52e73e49f71226d2e8e5685a1", size = 3641624, upload-time = "2026-02-27T17:26:13.542Z" }, + { url = "https://files.pythonhosted.org/packages/cc/02/9a6e4ca1f3f73a164c0cd48e41b3cc56585dcc37e809250de443d673266f/hf_xet-1.3.2-cp37-abi3-win_arm64.whl", hash = "sha256:83d8ec273136171431833a6957e8f3af496bee227a0fe47c7b8b39c106d1749a", size = 3503976, upload-time = "2026-02-27T17:26:12.123Z" }, ] [[package]] name = "hpack" version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 }, + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, ] [[package]] @@ -1725,9 +1725,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -1737,9 +1737,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyparsing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800 } +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099 }, + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, ] [[package]] @@ -1752,9 +1752,9 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [package.optional-dependencies] @@ -1777,27 +1777,27 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/76/b5efb3033d8499b17f9386beaf60f64c461798e1ee16d10bc9c0077beba5/huggingface_hub-1.5.0.tar.gz", hash = "sha256:f281838db29265880fb543de7a23b0f81d3504675de82044307ea3c6c62f799d", size = 695872 } +sdist = { url = "https://files.pythonhosted.org/packages/ae/76/b5efb3033d8499b17f9386beaf60f64c461798e1ee16d10bc9c0077beba5/huggingface_hub-1.5.0.tar.gz", hash = "sha256:f281838db29265880fb543de7a23b0f81d3504675de82044307ea3c6c62f799d", size = 695872, upload-time = "2026-02-26T15:35:32.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/74/2bc951622e2dbba1af9a460d93c51d15e458becd486e62c29cc0ccb08178/huggingface_hub-1.5.0-py3-none-any.whl", hash = "sha256:c9c0b3ab95a777fc91666111f3b3ede71c0cdced3614c553a64e98920585c4ee", size = 596261 }, + { url = "https://files.pythonhosted.org/packages/ec/74/2bc951622e2dbba1af9a460d93c51d15e458becd486e62c29cc0ccb08178/huggingface_hub-1.5.0-py3-none-any.whl", hash = "sha256:c9c0b3ab95a777fc91666111f3b3ede71c0cdced3614c553a64e98920585c4ee", size = 596261, upload-time = "2026-02-26T15:35:31.1Z" }, ] [[package]] name = "hyperframe" version = "6.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 }, + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] [[package]] name = "idna" version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245 } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340 }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -1807,45 +1807,45 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "invoke" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762 } +sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287 }, + { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, ] [[package]] name = "isodate" version = "0.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705 } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320 }, + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, ] [[package]] name = "isort" version = "8.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592 } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733 }, + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] [[package]] @@ -1855,95 +1855,95 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "jiter" version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958 }, - { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597 }, - { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821 }, - { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163 }, - { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709 }, - { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480 }, - { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735 }, - { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814 }, - { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990 }, - { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021 }, - { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024 }, - { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424 }, - { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818 }, - { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897 }, - { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507 }, - { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560 }, - { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232 }, - { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727 }, - { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799 }, - { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120 }, - { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664 }, - { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543 }, - { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262 }, - { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630 }, - { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602 }, - { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939 }, - { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616 }, - { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850 }, - { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551 }, - { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950 }, - { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852 }, - { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804 }, - { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787 }, - { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880 }, - { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702 }, - { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319 }, - { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289 }, - { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165 }, - { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634 }, - { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933 }, - { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842 }, - { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108 }, - { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027 }, - { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199 }, - { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423 }, - { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438 }, - { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774 }, - { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238 }, - { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892 }, - { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309 }, - { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607 }, - { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986 }, - { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756 }, - { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196 }, - { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215 }, - { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152 }, - { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169 }, - { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808 }, - { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384 }, - { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768 }, +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] [[package]] name = "jmespath" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 }, + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] [[package]] name = "joblib" version = "1.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603 } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071 }, + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] [[package]] @@ -1956,9 +1956,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, ] [[package]] @@ -1968,9 +1968,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[package]] @@ -1983,69 +1983,69 @@ dependencies = [ { name = "tzdata" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034 }, + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, ] [[package]] name = "librt" version = "0.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516 }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634 }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941 }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991 }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476 }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518 }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116 }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751 }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378 }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199 }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917 }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017 }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441 }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529 }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669 }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279 }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288 }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809 }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075 }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486 }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219 }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750 }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624 }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969 }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000 }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495 }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081 }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309 }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804 }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907 }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217 }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622 }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987 }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132 }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195 }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946 }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689 }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875 }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058 }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313 }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994 }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770 }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409 }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473 }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866 }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248 }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629 }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615 }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001 }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328 }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722 }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755 }, +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] [[package]] @@ -2067,15 +2067,15 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/fe/d03be8a6be6914fabacef29457a2b3d02128c52c448cc6f3019b8e63488b/litellm-1.96.2.tar.gz", hash = "sha256:80d477ae092b05ce023b5084542cb3cb75999b52b1dd2e4d834fb348effc9399", size = 17682558 } +sdist = { url = "https://files.pythonhosted.org/packages/79/fe/d03be8a6be6914fabacef29457a2b3d02128c52c448cc6f3019b8e63488b/litellm-1.96.2.tar.gz", hash = "sha256:80d477ae092b05ce023b5084542cb3cb75999b52b1dd2e4d834fb348effc9399", size = 17682558, upload-time = "2026-08-11T21:12:24.117Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/85/653d0095a163cc9a1f2f1aa629cf4493c57c3abe13bd806290fc5d57458b/litellm-1.96.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0aa667c6fc58b20ff04fe5efaac5cf48b1e224bac32dbf22ced2e5ee695bc4ba", size = 24102871 }, - { url = "https://files.pythonhosted.org/packages/29/a1/1322630271fbb291e97b54eb46baa8c4f512130a620a8428c7460b4ef414/litellm-1.96.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e5d96d16b37a043e482a134a71b0c1df4e700603fc14df22e4bd27dc9e38f5c4", size = 23760722 }, - { url = "https://files.pythonhosted.org/packages/17/0e/e0e2734194072a438298e94dbc3c37879d335fdf50881ce8995daa6878d1/litellm-1.96.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1a42d68b903f6b605bd7744e9700b16a725f897c5edcc448e491cec14cd5a9db", size = 23898969 }, - { url = "https://files.pythonhosted.org/packages/b5/84/993c40c116c699383741fd27da515c71b395609859148ea32720925eaeaf/litellm-1.96.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76e9c72cb6757bb7c35cd23e3e4fc028910795910ae92491076bd50f7b61f205", size = 24264588 }, - { url = "https://files.pythonhosted.org/packages/d8/02/21dc260ae3fd72b23544abce23ad5f3d563e3d39a4f617bba6d98f4b9c31/litellm-1.96.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1f304b8854e385946469d2dcf8ef88e24357120671caeaddea11b085b0302308", size = 23973160 }, - { url = "https://files.pythonhosted.org/packages/64/5d/e79f86b43f07aff7f796faf3c6d1025f6ab9da43f72b7c555a26811af191/litellm-1.96.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8c21a57a0f3507176492a4f65361a4302af1a0723c97e96961c7ed0e96934832", size = 24360615 }, - { url = "https://files.pythonhosted.org/packages/3e/6a/c2e3058fd3766eebe5685c688d9e85adb9749e946580f46214a651661522/litellm-1.96.2-cp310-abi3-win_amd64.whl", hash = "sha256:0168e49cffecb0b45a0d044ecb7e5f7f6fc9e14d974b50f54edfa9d8e0db9c1d", size = 24154302 }, + { url = "https://files.pythonhosted.org/packages/46/85/653d0095a163cc9a1f2f1aa629cf4493c57c3abe13bd806290fc5d57458b/litellm-1.96.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0aa667c6fc58b20ff04fe5efaac5cf48b1e224bac32dbf22ced2e5ee695bc4ba", size = 24102871, upload-time = "2026-08-11T21:12:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/29/a1/1322630271fbb291e97b54eb46baa8c4f512130a620a8428c7460b4ef414/litellm-1.96.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e5d96d16b37a043e482a134a71b0c1df4e700603fc14df22e4bd27dc9e38f5c4", size = 23760722, upload-time = "2026-08-11T21:12:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/e0e2734194072a438298e94dbc3c37879d335fdf50881ce8995daa6878d1/litellm-1.96.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1a42d68b903f6b605bd7744e9700b16a725f897c5edcc448e491cec14cd5a9db", size = 23898969, upload-time = "2026-08-11T21:12:11.68Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/993c40c116c699383741fd27da515c71b395609859148ea32720925eaeaf/litellm-1.96.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76e9c72cb6757bb7c35cd23e3e4fc028910795910ae92491076bd50f7b61f205", size = 24264588, upload-time = "2026-08-11T21:12:14.18Z" }, + { url = "https://files.pythonhosted.org/packages/d8/02/21dc260ae3fd72b23544abce23ad5f3d563e3d39a4f617bba6d98f4b9c31/litellm-1.96.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1f304b8854e385946469d2dcf8ef88e24357120671caeaddea11b085b0302308", size = 23973160, upload-time = "2026-08-11T21:12:16.616Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/e79f86b43f07aff7f796faf3c6d1025f6ab9da43f72b7c555a26811af191/litellm-1.96.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8c21a57a0f3507176492a4f65361a4302af1a0723c97e96961c7ed0e96934832", size = 24360615, upload-time = "2026-08-11T21:12:18.947Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6a/c2e3058fd3766eebe5685c688d9e85adb9749e946580f46214a651661522/litellm-1.96.2-cp310-abi3-win_amd64.whl", hash = "sha256:0168e49cffecb0b45a0d044ecb7e5f7f6fc9e14d974b50f54edfa9d8e0db9c1d", size = 24154302, upload-time = "2026-08-11T21:12:21.221Z" }, ] [[package]] @@ -2087,9 +2087,9 @@ dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/72/816e6e900448e1b4a8137d90e65876b296c5264a23db6ae888bd3e6660ba/llama_cloud-0.1.35.tar.gz", hash = "sha256:200349d5d57424d7461f304cdb1355a58eea3e6ca1e6b0d75c66b2e937216983", size = 106403 } +sdist = { url = "https://files.pythonhosted.org/packages/9b/72/816e6e900448e1b4a8137d90e65876b296c5264a23db6ae888bd3e6660ba/llama_cloud-0.1.35.tar.gz", hash = "sha256:200349d5d57424d7461f304cdb1355a58eea3e6ca1e6b0d75c66b2e937216983", size = 106403, upload-time = "2025-07-28T17:22:06.41Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/d2/8d18a021ab757cea231428404f21fe3186bf1ebaac3f57a73c379483fd3f/llama_cloud-0.1.35-py3-none-any.whl", hash = "sha256:b7abab4423118e6f638d2f326749e7a07c6426543bea6da99b623c715b22af71", size = 303280 }, + { url = "https://files.pythonhosted.org/packages/1d/d2/8d18a021ab757cea231428404f21fe3186bf1ebaac3f57a73c379483fd3f/llama_cloud-0.1.35-py3-none-any.whl", hash = "sha256:b7abab4423118e6f638d2f326749e7a07c6426543bea6da99b623c715b22af71", size = 303280, upload-time = "2025-07-28T17:22:04.946Z" }, ] [[package]] @@ -2105,9 +2105,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/0c/8ca87d33bea0340a8ed791f36390112aeb29fd3eebfd64b6aef6204a03f0/llama_cloud_services-0.6.54.tar.gz", hash = "sha256:baf65d9bffb68f9dca98ac6e22908b6675b2038b021e657ead1ffc0e43cbd45d", size = 53468 } +sdist = { url = "https://files.pythonhosted.org/packages/8a/0c/8ca87d33bea0340a8ed791f36390112aeb29fd3eebfd64b6aef6204a03f0/llama_cloud_services-0.6.54.tar.gz", hash = "sha256:baf65d9bffb68f9dca98ac6e22908b6675b2038b021e657ead1ffc0e43cbd45d", size = 53468, upload-time = "2025-08-01T20:09:20.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/48/4e295e3f791b279885a2e584f71e75cbe4ac84e93bba3c36e2668f60a8ac/llama_cloud_services-0.6.54-py3-none-any.whl", hash = "sha256:07f595f7a0ba40c6a1a20543d63024ca7600fe65c4811d1951039977908997be", size = 63874 }, + { url = "https://files.pythonhosted.org/packages/7f/48/4e295e3f791b279885a2e584f71e75cbe4ac84e93bba3c36e2668f60a8ac/llama_cloud_services-0.6.54-py3-none-any.whl", hash = "sha256:07f595f7a0ba40c6a1a20543d63024ca7600fe65c4811d1951039977908997be", size = 63874, upload-time = "2025-08-01T20:09:20.076Z" }, ] [[package]] @@ -2124,9 +2124,9 @@ dependencies = [ { name = "llama-index-readers-llama-parse" }, { name = "nltk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/25/d74e56acf693c608bfa2269adfd5a58128973aaa7fd1e77ccf9d5f616f32/llama_index-0.14.15.tar.gz", hash = "sha256:079f65e72af87c72dd8b516aa2dd520b52eb2128722d66ecce1e5148cee357c0", size = 8472 } +sdist = { url = "https://files.pythonhosted.org/packages/20/25/d74e56acf693c608bfa2269adfd5a58128973aaa7fd1e77ccf9d5f616f32/llama_index-0.14.15.tar.gz", hash = "sha256:079f65e72af87c72dd8b516aa2dd520b52eb2128722d66ecce1e5148cee357c0", size = 8472, upload-time = "2026-02-18T19:06:38.527Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/94/b338e8985313e6e3a5321638f3d7d457310da6cb4ab1298eea3b323cb06c/llama_index-0.14.15-py3-none-any.whl", hash = "sha256:469bf8ff77a445dbf402ed08978a0c8ebf59d40fcd15d289e07e5791e0513cea", size = 7264 }, + { url = "https://files.pythonhosted.org/packages/02/94/b338e8985313e6e3a5321638f3d7d457310da6cb4ab1298eea3b323cb06c/llama_index-0.14.15-py3-none-any.whl", hash = "sha256:469bf8ff77a445dbf402ed08978a0c8ebf59d40fcd15d289e07e5791e0513cea", size = 7264, upload-time = "2026-02-18T19:06:39.54Z" }, ] [[package]] @@ -2138,9 +2138,9 @@ dependencies = [ { name = "llama-index-embeddings-openai" }, { name = "llama-index-llms-openai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/84/41e820efffbe327c38228d3b37fe42512a37e0c3ee4ff6bf97a394e9577a/llama_index_cli-0.5.3.tar.gz", hash = "sha256:ebaf39e785efbfa8d50d837f60cb0f95125c04bf73ed1f92092a2a5f506172f8", size = 24821 } +sdist = { url = "https://files.pythonhosted.org/packages/67/84/41e820efffbe327c38228d3b37fe42512a37e0c3ee4ff6bf97a394e9577a/llama_index_cli-0.5.3.tar.gz", hash = "sha256:ebaf39e785efbfa8d50d837f60cb0f95125c04bf73ed1f92092a2a5f506172f8", size = 24821, upload-time = "2025-09-29T18:03:10.798Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/81/b7b3778aa8662913760fbbee77578daf4407aeaa677ccbf0125c4cfa2e67/llama_index_cli-0.5.3-py3-none-any.whl", hash = "sha256:7deb1e953e582bd885443881ce8bd6ab2817b594fef00079dce9993c47d990f7", size = 28173 }, + { url = "https://files.pythonhosted.org/packages/54/81/b7b3778aa8662913760fbbee77578daf4407aeaa677ccbf0125c4cfa2e67/llama_index_cli-0.5.3-py3-none-any.whl", hash = "sha256:7deb1e953e582bd885443881ce8bd6ab2817b594fef00079dce9993c47d990f7", size = 28173, upload-time = "2025-09-29T18:03:10.024Z" }, ] [[package]] @@ -2177,9 +2177,9 @@ dependencies = [ { name = "typing-inspect" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/4f/7c714bdf94dd229707b43e7f8cedf3aed0a99938fd46a9ad8a418c199988/llama_index_core-0.14.15.tar.gz", hash = "sha256:3766aeeb95921b3a2af8c2a51d844f75f404215336e1639098e3652db52c68ce", size = 11593505 } +sdist = { url = "https://files.pythonhosted.org/packages/0c/4f/7c714bdf94dd229707b43e7f8cedf3aed0a99938fd46a9ad8a418c199988/llama_index_core-0.14.15.tar.gz", hash = "sha256:3766aeeb95921b3a2af8c2a51d844f75f404215336e1639098e3652db52c68ce", size = 11593505, upload-time = "2026-02-18T19:05:48.274Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/9e/262f6465ee4fffa40698b3cc2177e377ce7d945d3bd8b7d9c6b09448625d/llama_index_core-0.14.15-py3-none-any.whl", hash = "sha256:e02b321c10673871a38aaefdc4a93d5ae8ec324cad4408683189e5a1aa1e3d52", size = 11937002 }, + { url = "https://files.pythonhosted.org/packages/41/9e/262f6465ee4fffa40698b3cc2177e377ce7d945d3bd8b7d9c6b09448625d/llama_index_core-0.14.15-py3-none-any.whl", hash = "sha256:e02b321c10673871a38aaefdc4a93d5ae8ec324cad4408683189e5a1aa1e3d52", size = 11937002, upload-time = "2026-02-18T19:05:45.855Z" }, ] [[package]] @@ -2190,9 +2190,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "openai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/36/90336d054a5061a3f5bc17ac2c18ef63d9d84c55c14d557de484e811ea4d/llama_index_embeddings_openai-0.5.1.tar.gz", hash = "sha256:1c89867a48b0d0daa3d2d44f5e76b394b2b2ef9935932daf921b9e77939ccda8", size = 7020 } +sdist = { url = "https://files.pythonhosted.org/packages/10/36/90336d054a5061a3f5bc17ac2c18ef63d9d84c55c14d557de484e811ea4d/llama_index_embeddings_openai-0.5.1.tar.gz", hash = "sha256:1c89867a48b0d0daa3d2d44f5e76b394b2b2ef9935932daf921b9e77939ccda8", size = 7020, upload-time = "2025-09-08T20:17:44.681Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/4a/8ab11026cf8deff8f555aa73919be0bac48332683111e5fc4290f352dc50/llama_index_embeddings_openai-0.5.1-py3-none-any.whl", hash = "sha256:a2fcda3398bbd987b5ce3f02367caee8e84a56b930fdf43cc1d059aa9fd20ca5", size = 7011 }, + { url = "https://files.pythonhosted.org/packages/23/4a/8ab11026cf8deff8f555aa73919be0bac48332683111e5fc4290f352dc50/llama_index_embeddings_openai-0.5.1-py3-none-any.whl", hash = "sha256:a2fcda3398bbd987b5ce3f02367caee8e84a56b930fdf43cc1d059aa9fd20ca5", size = 7011, upload-time = "2025-09-08T20:17:44.015Z" }, ] [[package]] @@ -2204,9 +2204,9 @@ dependencies = [ { name = "llama-cloud" }, { name = "llama-index-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/4a/79044fcb3209583d1ffe0c2a7c19dddfb657a03faeb9fe0cf5a74027e646/llama_index_indices_managed_llama_cloud-0.9.4.tar.gz", hash = "sha256:b5e00752ab30564abf19c57595a2107f5697c3b03b085817b4fca84a38ebbd59", size = 15146 } +sdist = { url = "https://files.pythonhosted.org/packages/61/4a/79044fcb3209583d1ffe0c2a7c19dddfb657a03faeb9fe0cf5a74027e646/llama_index_indices_managed_llama_cloud-0.9.4.tar.gz", hash = "sha256:b5e00752ab30564abf19c57595a2107f5697c3b03b085817b4fca84a38ebbd59", size = 15146, upload-time = "2025-09-08T20:29:58.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/6a/0e33245df06afc9766c46a1fe92687be8a09da5d0d0128bc08d84a9f5efa/llama_index_indices_managed_llama_cloud-0.9.4-py3-none-any.whl", hash = "sha256:535a08811046803ca6ab7f8e9d510e926aa5306608b02201ad3d9d21701383bc", size = 17005 }, + { url = "https://files.pythonhosted.org/packages/a6/6a/0e33245df06afc9766c46a1fe92687be8a09da5d0d0128bc08d84a9f5efa/llama_index_indices_managed_llama_cloud-0.9.4-py3-none-any.whl", hash = "sha256:535a08811046803ca6ab7f8e9d510e926aa5306608b02201ad3d9d21701383bc", size = 17005, upload-time = "2025-09-08T20:29:57.876Z" }, ] [[package]] @@ -2217,9 +2217,9 @@ dependencies = [ { name = "deprecated" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/b9/a7a74de6d8aacf4be329329495983d78d96b1a6e69b6d9fcf4a233febd4b/llama_index_instrumentation-0.4.2.tar.gz", hash = "sha256:dc4957b64da0922060690e85a6be9698ac08e34e0f69e90b01364ddec4f3de7f", size = 46146 } +sdist = { url = "https://files.pythonhosted.org/packages/af/b9/a7a74de6d8aacf4be329329495983d78d96b1a6e69b6d9fcf4a233febd4b/llama_index_instrumentation-0.4.2.tar.gz", hash = "sha256:dc4957b64da0922060690e85a6be9698ac08e34e0f69e90b01364ddec4f3de7f", size = 46146, upload-time = "2025-10-13T20:44:48.85Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/54/df8063b0441242e250e03d1e31ebde5dffbe24e1af32b025cb1a4544150c/llama_index_instrumentation-0.4.2-py3-none-any.whl", hash = "sha256:b4989500e6454059ab3f3c4a193575d47ab1fadb730c2e8f2b962649ae88b70b", size = 15411 }, + { url = "https://files.pythonhosted.org/packages/40/54/df8063b0441242e250e03d1e31ebde5dffbe24e1af32b025cb1a4544150c/llama_index_instrumentation-0.4.2-py3-none-any.whl", hash = "sha256:b4989500e6454059ab3f3c4a193575d47ab1fadb730c2e8f2b962649ae88b70b", size = 15411, upload-time = "2025-10-13T20:44:47.685Z" }, ] [[package]] @@ -2230,9 +2230,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "openai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/d4/cb0ab1aa47d00fc251fad4dfe30093fc964dba9b168a88aaacd0411b5a42/llama_index_llms_openai-0.6.23.tar.gz", hash = "sha256:4208404bbf6c5a18bed2079df9333f4ba931e5b8c6c6a407abe2c47c14ac7b7e", size = 26595 } +sdist = { url = "https://files.pythonhosted.org/packages/99/d4/cb0ab1aa47d00fc251fad4dfe30093fc964dba9b168a88aaacd0411b5a42/llama_index_llms_openai-0.6.23.tar.gz", hash = "sha256:4208404bbf6c5a18bed2079df9333f4ba931e5b8c6c6a407abe2c47c14ac7b7e", size = 26595, upload-time = "2026-03-01T17:51:14.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/4e/760a0cde3ffb5f4a85660441c01b80d67e579edc99b69fe4eb20799642e2/llama_index_llms_openai-0.6.23-py3-none-any.whl", hash = "sha256:48c1bb2d0adaca7bb7477c3f84c4c70f6f5c2ced942ad02773e70fa035b5b323", size = 27618 }, + { url = "https://files.pythonhosted.org/packages/55/4e/760a0cde3ffb5f4a85660441c01b80d67e579edc99b69fe4eb20799642e2/llama_index_llms_openai-0.6.23-py3-none-any.whl", hash = "sha256:48c1bb2d0adaca7bb7477c3f84c4c70f6f5c2ced942ad02773e70fa035b5b323", size = 27618, upload-time = "2026-03-01T17:51:16.452Z" }, ] [[package]] @@ -2247,9 +2247,9 @@ dependencies = [ { name = "pypdf" }, { name = "striprtf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/e5/dccfb495dbc40f50fcfb799db2287ac5dca4a16a3b09bae61a4ccb1788d3/llama_index_readers_file-0.5.6.tar.gz", hash = "sha256:1c08b14facc2dfe933622aaa26dc7d2a7a6023c42d3db896a2c948789edaf1ea", size = 32535 } +sdist = { url = "https://files.pythonhosted.org/packages/a0/e5/dccfb495dbc40f50fcfb799db2287ac5dca4a16a3b09bae61a4ccb1788d3/llama_index_readers_file-0.5.6.tar.gz", hash = "sha256:1c08b14facc2dfe933622aaa26dc7d2a7a6023c42d3db896a2c948789edaf1ea", size = 32535, upload-time = "2025-12-24T16:04:16.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/c3/8d28eaa962e073e6735d80847dda9fd3525cb9ff5974ae82dd20621a5a02/llama_index_readers_file-0.5.6-py3-none-any.whl", hash = "sha256:32e83f9adb4e4803e6c7cef746c44fa0949013b1cb76f06f422e9491d198dbda", size = 51832 }, + { url = "https://files.pythonhosted.org/packages/fe/c3/8d28eaa962e073e6735d80847dda9fd3525cb9ff5974ae82dd20621a5a02/llama_index_readers_file-0.5.6-py3-none-any.whl", hash = "sha256:32e83f9adb4e4803e6c7cef746c44fa0949013b1cb76f06f422e9491d198dbda", size = 51832, upload-time = "2025-12-24T16:04:17.307Z" }, ] [[package]] @@ -2260,9 +2260,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "llama-parse" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/77/5bfaab20e6ec8428dbf2352e18be550c957602723d69383908176b5686cd/llama_index_readers_llama_parse-0.5.1.tar.gz", hash = "sha256:2b78b73faa933e30e6c69df351e4e9f36dfe2ae142e2ab3969ddd2ac48930e37", size = 3858 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/77/5bfaab20e6ec8428dbf2352e18be550c957602723d69383908176b5686cd/llama_index_readers_llama_parse-0.5.1.tar.gz", hash = "sha256:2b78b73faa933e30e6c69df351e4e9f36dfe2ae142e2ab3969ddd2ac48930e37", size = 3858, upload-time = "2025-09-08T20:41:29.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/81/52410c7245dcbf1a54756a9ce3892cdd167ec0b884d696de1304ca3f452e/llama_index_readers_llama_parse-0.5.1-py3-none-any.whl", hash = "sha256:0d41450ed29b0c49c024e206ef6c8e662b1854e77a1c5faefed3b958be54f880", size = 3203 }, + { url = "https://files.pythonhosted.org/packages/68/81/52410c7245dcbf1a54756a9ce3892cdd167ec0b884d696de1304ca3f452e/llama_index_readers_llama_parse-0.5.1-py3-none-any.whl", hash = "sha256:0d41450ed29b0c49c024e206ef6c8e662b1854e77a1c5faefed3b958be54f880", size = 3203, upload-time = "2025-09-08T20:41:28.438Z" }, ] [[package]] @@ -2273,9 +2273,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "pymilvus" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/79/72bf70fac3b77770a5c2b1b7d441aa1998bb522d50fe88b0f9c4071854e5/llama_index_vector_stores_milvus-0.9.6.tar.gz", hash = "sha256:6d38ac5939a570e0240687f54fbee4e1ff6c5faa2d28d25377a3f38d2ca07e2b", size = 15584 } +sdist = { url = "https://files.pythonhosted.org/packages/ef/79/72bf70fac3b77770a5c2b1b7d441aa1998bb522d50fe88b0f9c4071854e5/llama_index_vector_stores_milvus-0.9.6.tar.gz", hash = "sha256:6d38ac5939a570e0240687f54fbee4e1ff6c5faa2d28d25377a3f38d2ca07e2b", size = 15584, upload-time = "2026-01-13T11:46:41.394Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/04/98359849c095b5a3eb06f0649865a30b5a733b9fdbfeac3c6951253f804c/llama_index_vector_stores_milvus-0.9.6-py3-none-any.whl", hash = "sha256:916cbd9b07035ec137905970ef6a49dd77d3ece6e0a79271db35705cca5f5f84", size = 15792 }, + { url = "https://files.pythonhosted.org/packages/51/04/98359849c095b5a3eb06f0649865a30b5a733b9fdbfeac3c6951253f804c/llama_index_vector_stores_milvus-0.9.6-py3-none-any.whl", hash = "sha256:916cbd9b07035ec137905970ef6a49dd77d3ece6e0a79271db35705cca5f5f84", size = 15792, upload-time = "2026-01-13T11:46:39.708Z" }, ] [[package]] @@ -2286,9 +2286,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "pinecone" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/a0/2e2e969a133894f10b3a55b5148feef0c546ca8047b461f51f79d115c5b9/llama_index_vector_stores_pinecone-0.7.1.tar.gz", hash = "sha256:0ab3cc44f309bca1d74e58f221dade672169da01561114b067f4734293bd0280", size = 7852 } +sdist = { url = "https://files.pythonhosted.org/packages/5c/a0/2e2e969a133894f10b3a55b5148feef0c546ca8047b461f51f79d115c5b9/llama_index_vector_stores_pinecone-0.7.1.tar.gz", hash = "sha256:0ab3cc44f309bca1d74e58f221dade672169da01561114b067f4734293bd0280", size = 7852, upload-time = "2025-09-08T20:28:54.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/22/ae8c3073e4866a41eb53030db7cfecca1d84192c16a67d40a76f8d593e6d/llama_index_vector_stores_pinecone-0.7.1-py3-none-any.whl", hash = "sha256:861c4d01b3766cdca318f1285c03cd5e52dabf3d2f136cb38db421b16103129a", size = 8041 }, + { url = "https://files.pythonhosted.org/packages/93/22/ae8c3073e4866a41eb53030db7cfecca1d84192c16a67d40a76f8d593e6d/llama_index_vector_stores_pinecone-0.7.1-py3-none-any.whl", hash = "sha256:861c4d01b3766cdca318f1285c03cd5e52dabf3d2f136cb38db421b16103129a", size = 8041, upload-time = "2025-09-08T20:28:53.406Z" }, ] [[package]] @@ -2302,9 +2302,9 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "sqlalchemy", extra = ["asyncio"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/78/04ff0cb9e14b8c1c3cb8716fab35c95ec2a4b551d769c65031c5c8624337/llama_index_vector_stores_postgres-0.7.3.tar.gz", hash = "sha256:7b5c62e462d681d7b8d8668b93e5b0023bfd3aaafcf76e2b4bfcf885dc3b49c6", size = 11950 } +sdist = { url = "https://files.pythonhosted.org/packages/ef/78/04ff0cb9e14b8c1c3cb8716fab35c95ec2a4b551d769c65031c5c8624337/llama_index_vector_stores_postgres-0.7.3.tar.gz", hash = "sha256:7b5c62e462d681d7b8d8668b93e5b0023bfd3aaafcf76e2b4bfcf885dc3b49c6", size = 11950, upload-time = "2026-01-22T15:14:13.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/de/140f678d930ea869fc989adaa0140b9267c5ee0c7b971d061112d0d5b75a/llama_index_vector_stores_postgres-0.7.3-py3-none-any.whl", hash = "sha256:65b70266cc6041ab5011d64d1183d8783112ba5b38eb32ca21e00ea5b96aa058", size = 11635 }, + { url = "https://files.pythonhosted.org/packages/cc/de/140f678d930ea869fc989adaa0140b9267c5ee0c7b971d061112d0d5b75a/llama_index_vector_stores_postgres-0.7.3-py3-none-any.whl", hash = "sha256:65b70266cc6041ab5011d64d1183d8783112ba5b38eb32ca21e00ea5b96aa058", size = 11635, upload-time = "2026-01-22T15:14:13.722Z" }, ] [[package]] @@ -2316,9 +2316,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "qdrant-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/63/646ec9d7035429d4fb5488146ab7a9d55e955fb855f0a7e7fc29f6eb136f/llama_index_vector_stores_qdrant-0.9.1.tar.gz", hash = "sha256:215e24278bde44c6746d60c7df3f8811f943c20524a496e0c954eeb6449e8319", size = 14688 } +sdist = { url = "https://files.pythonhosted.org/packages/99/63/646ec9d7035429d4fb5488146ab7a9d55e955fb855f0a7e7fc29f6eb136f/llama_index_vector_stores_qdrant-0.9.1.tar.gz", hash = "sha256:215e24278bde44c6746d60c7df3f8811f943c20524a496e0c954eeb6449e8319", size = 14688, upload-time = "2026-01-13T11:13:07.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/d2/e258a2b5526fe42d6ca60a054b3830948d6411e3f075de542ba492c5f5d1/llama_index_vector_stores_qdrant-0.9.1-py3-none-any.whl", hash = "sha256:de71d0e867c04c87aa81ab35b092c32d684961c26a1cda601856faf29b21a598", size = 14944 }, + { url = "https://files.pythonhosted.org/packages/1d/d2/e258a2b5526fe42d6ca60a054b3830948d6411e3f075de542ba492c5f5d1/llama_index_vector_stores_qdrant-0.9.1-py3-none-any.whl", hash = "sha256:de71d0e867c04c87aa81ab35b092c32d684961c26a1cda601856faf29b21a598", size = 14944, upload-time = "2026-01-13T11:13:08.08Z" }, ] [[package]] @@ -2329,9 +2329,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "weaviate-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/9c/fd6eae66b87e736807776c6824bebebb44098630af2dac8cd5cdf5938d0d/llama_index_vector_stores_weaviate-1.5.0.tar.gz", hash = "sha256:99ba6dbdcf92e9ec56f464de2d71ed3c0503e3fc5b71f9d74dbc32da981b0cf5", size = 9679 } +sdist = { url = "https://files.pythonhosted.org/packages/3d/9c/fd6eae66b87e736807776c6824bebebb44098630af2dac8cd5cdf5938d0d/llama_index_vector_stores_weaviate-1.5.0.tar.gz", hash = "sha256:99ba6dbdcf92e9ec56f464de2d71ed3c0503e3fc5b71f9d74dbc32da981b0cf5", size = 9679, upload-time = "2026-02-20T23:20:19.83Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/dd/09efa4551016a9e95d53dbb3cd7ae7a9bf3fd07be1a3e1ac3eada7b4c5e5/llama_index_vector_stores_weaviate-1.5.0-py3-none-any.whl", hash = "sha256:8e24d920a0cc241dcf0cdbfe29541aeca6a9f8cf29606ed90325978f972636a5", size = 10439 }, + { url = "https://files.pythonhosted.org/packages/22/dd/09efa4551016a9e95d53dbb3cd7ae7a9bf3fd07be1a3e1ac3eada7b4c5e5/llama_index_vector_stores_weaviate-1.5.0-py3-none-any.whl", hash = "sha256:8e24d920a0cc241dcf0cdbfe29541aeca6a9f8cf29606ed90325978f972636a5", size = 10439, upload-time = "2026-02-20T23:20:19.057Z" }, ] [[package]] @@ -2343,9 +2343,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/21/0357ededbc3aead4d170ba5a7b33345ee45e07f77423367725ef623c08a6/llama_index_workflows-2.15.0.tar.gz", hash = "sha256:06630ca3887b9bf27da776f10e42811aeedf3534591eb654796319ef898d2d7b", size = 81504 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/21/0357ededbc3aead4d170ba5a7b33345ee45e07f77423367725ef623c08a6/llama_index_workflows-2.15.0.tar.gz", hash = "sha256:06630ca3887b9bf27da776f10e42811aeedf3534591eb654796319ef898d2d7b", size = 81504, upload-time = "2026-02-28T00:02:28.904Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/43/ae0902d0ec6d75b6724f8db8de7cebaac7b2e7d285d31ac5f58279973d9b/llama_index_workflows-2.15.0-py3-none-any.whl", hash = "sha256:31634e32dcc2ec248b8c4a03d54c98a8e87cefce641edd573bdbd548d89f3c0c", size = 104367 }, + { url = "https://files.pythonhosted.org/packages/b3/43/ae0902d0ec6d75b6724f8db8de7cebaac7b2e7d285d31ac5f58279973d9b/llama_index_workflows-2.15.0-py3-none-any.whl", hash = "sha256:31634e32dcc2ec248b8c4a03d54c98a8e87cefce641edd573bdbd548d89f3c0c", size = 104367, upload-time = "2026-02-28T00:02:27.074Z" }, ] [[package]] @@ -2355,9 +2355,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llama-cloud-services" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/f6/93b5d123c480bc8c93e6dc3ea930f4f8df8da27f829bb011100ba3ce23dc/llama_parse-0.6.54.tar.gz", hash = "sha256:c707b31152155c9bae84e316fab790bbc8c85f4d8825ce5ee386ebeb7db258f1", size = 3577 } +sdist = { url = "https://files.pythonhosted.org/packages/08/f6/93b5d123c480bc8c93e6dc3ea930f4f8df8da27f829bb011100ba3ce23dc/llama_parse-0.6.54.tar.gz", hash = "sha256:c707b31152155c9bae84e316fab790bbc8c85f4d8825ce5ee386ebeb7db258f1", size = 3577, upload-time = "2025-08-01T20:09:23.762Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/50/c5ccd2a50daa0a10c7f3f7d4e6992392454198cd8a7d99fcb96cb60d0686/llama_parse-0.6.54-py3-none-any.whl", hash = "sha256:c66c8d51cf6f29a44eaa8595a595de5d2598afc86e5a33a4cebe5fe228036920", size = 4879 }, + { url = "https://files.pythonhosted.org/packages/05/50/c5ccd2a50daa0a10c7f3f7d4e6992392454198cd8a7d99fcb96cb60d0686/llama_parse-0.6.54-py3-none-any.whl", hash = "sha256:c66c8d51cf6f29a44eaa8595a595de5d2598afc86e5a33a4cebe5fe228036920", size = 4879, upload-time = "2025-08-01T20:09:22.651Z" }, ] [[package]] @@ -2368,9 +2368,9 @@ dependencies = [ { name = "requests" }, { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/7a/d008832e730fe64fe3a24a1a1fcc6b6548130abd987fb6576ba60ab836f4/llmwhisperer_client-2.8.1.tar.gz", hash = "sha256:9e5ee18bf79c6b3a2ea598a4a9d80340ad6d28743fe96b0d3bae7a1b8c9b9c22", size = 3270865 } +sdist = { url = "https://files.pythonhosted.org/packages/34/7a/d008832e730fe64fe3a24a1a1fcc6b6548130abd987fb6576ba60ab836f4/llmwhisperer_client-2.8.1.tar.gz", hash = "sha256:9e5ee18bf79c6b3a2ea598a4a9d80340ad6d28743fe96b0d3bae7a1b8c9b9c22", size = 3270865, upload-time = "2026-08-18T11:48:52.316Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/e7/8e22bb7ef903b1168d0e06542e558a4f818bb0f4966dd5740e1e5358396e/llmwhisperer_client-2.8.1-py3-none-any.whl", hash = "sha256:3f261a3d343088873c4b5e34ffc016e6c53936c09fbcf8d78318f9733174ecdd", size = 12076 }, + { url = "https://files.pythonhosted.org/packages/f1/e7/8e22bb7ef903b1168d0e06542e558a4f818bb0f4966dd5740e1e5358396e/llmwhisperer_client-2.8.1-py3-none-any.whl", hash = "sha256:3f261a3d343088873c4b5e34ffc016e6c53936c09fbcf8d78318f9733174ecdd", size = 12076, upload-time = "2026-08-18T11:48:50.794Z" }, ] [[package]] @@ -2380,72 +2380,72 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -2455,27 +2455,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095 } +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964 }, + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] [[package]] name = "mccabe" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -2486,10 +2486,10 @@ dependencies = [ { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/b2/acc5024c8e8b6a0b034670b8e8af306ebd633ede777dcbf557eac4785937/milvus_lite-2.5.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:6b014453200ba977be37ba660cb2d021030375fa6a35bc53c2e1d92980a0c512", size = 27934713 }, - { url = "https://files.pythonhosted.org/packages/9b/2e/746f5bb1d6facd1e73eb4af6dd5efda11125b0f29d7908a097485ca6cad9/milvus_lite-2.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2e031088bf308afe5f8567850412d618cfb05a65238ed1a6117f60decccc95a", size = 24421451 }, - { url = "https://files.pythonhosted.org/packages/2e/cf/3d1fee5c16c7661cf53977067a34820f7269ed8ba99fe9cf35efc1700866/milvus_lite-2.5.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a13277e9bacc6933dea172e42231f7e6135bd3bdb073dd2688ee180418abd8d9", size = 45337093 }, - { url = "https://files.pythonhosted.org/packages/d3/82/41d9b80f09b82e066894d9b508af07b7b0fa325ce0322980674de49106a0/milvus_lite-2.5.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25ce13f4b8d46876dd2b7ac8563d7d8306da7ff3999bb0d14b116b30f71d706c", size = 55263911 }, + { url = "https://files.pythonhosted.org/packages/a9/b2/acc5024c8e8b6a0b034670b8e8af306ebd633ede777dcbf557eac4785937/milvus_lite-2.5.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:6b014453200ba977be37ba660cb2d021030375fa6a35bc53c2e1d92980a0c512", size = 27934713, upload-time = "2025-06-30T04:23:37.028Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2e/746f5bb1d6facd1e73eb4af6dd5efda11125b0f29d7908a097485ca6cad9/milvus_lite-2.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2e031088bf308afe5f8567850412d618cfb05a65238ed1a6117f60decccc95a", size = 24421451, upload-time = "2025-06-30T04:23:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cf/3d1fee5c16c7661cf53977067a34820f7269ed8ba99fe9cf35efc1700866/milvus_lite-2.5.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a13277e9bacc6933dea172e42231f7e6135bd3bdb073dd2688ee180418abd8d9", size = 45337093, upload-time = "2025-06-30T04:24:06.706Z" }, + { url = "https://files.pythonhosted.org/packages/d3/82/41d9b80f09b82e066894d9b508af07b7b0fa325ce0322980674de49106a0/milvus_lite-2.5.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25ce13f4b8d46876dd2b7ac8563d7d8306da7ff3999bb0d14b116b30f71d706c", size = 55263911, upload-time = "2025-06-30T04:24:19.434Z" }, ] [[package]] @@ -2501,9 +2501,9 @@ dependencies = [ { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/ec/52e6c9ad90ad7eb3035f5e511123e89d1ecc7617f0c94653264848623c12/msal-1.35.0.tar.gz", hash = "sha256:76ab7513dbdac88d76abdc6a50110f082b7ed3ff1080aca938c53fc88bc75b51", size = 164057 } +sdist = { url = "https://files.pythonhosted.org/packages/95/ec/52e6c9ad90ad7eb3035f5e511123e89d1ecc7617f0c94653264848623c12/msal-1.35.0.tar.gz", hash = "sha256:76ab7513dbdac88d76abdc6a50110f082b7ed3ff1080aca938c53fc88bc75b51", size = 164057, upload-time = "2026-02-24T10:58:28.415Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/26/5463e615de18ad8b80d75d14c612ef3c866fcc07c1c52e8eac7948984214/msal-1.35.0-py3-none-any.whl", hash = "sha256:baf268172d2b736e5d409689424d2f321b4142cab231b4b96594c86762e7e01d", size = 120082 }, + { url = "https://files.pythonhosted.org/packages/56/26/5463e615de18ad8b80d75d14c612ef3c866fcc07c1c52e8eac7948984214/msal-1.35.0-py3-none-any.whl", hash = "sha256:baf268172d2b736e5d409689424d2f321b4142cab231b4b96594c86762e7e01d", size = 120082, upload-time = "2026-02-24T10:58:27.219Z" }, ] [[package]] @@ -2513,108 +2513,108 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "msal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315 } +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583 }, + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] [[package]] name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893 }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456 }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872 }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018 }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883 }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413 }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404 }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456 }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322 }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955 }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254 }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059 }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588 }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642 }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377 }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887 }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053 }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307 }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174 }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116 }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524 }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368 }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952 }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317 }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132 }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140 }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277 }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291 }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156 }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742 }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221 }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664 }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490 }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695 }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884 }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122 }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175 }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460 }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930 }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582 }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031 }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596 }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492 }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899 }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970 }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060 }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888 }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554 }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341 }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391 }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422 }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770 }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109 }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573 }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190 }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486 }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219 }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132 }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420 }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510 }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094 }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786 }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483 }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403 }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315 }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528 }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784 }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980 }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602 }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930 }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074 }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471 }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401 }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143 }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507 }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358 }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884 }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878 }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542 }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403 }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889 }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982 }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415 }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337 }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788 }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842 }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237 }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008 }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542 }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719 }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 }, +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] @@ -2627,54 +2627,54 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053 }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134 }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616 }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847 }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976 }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104 }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927 }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730 }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581 }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252 }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848 }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510 }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744 }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815 }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047 }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998 }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476 }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872 }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239 }, +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "nest-asyncio" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] [[package]] name = "networkx" version = "3.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504 }, + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] [[package]] @@ -2687,70 +2687,70 @@ dependencies = [ { name = "regex" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864 } +sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087 }, + { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" }, ] [[package]] name = "numpy" version = "2.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963 }, - { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571 }, - { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469 }, - { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820 }, - { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067 }, - { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782 }, - { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128 }, - { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324 }, - { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282 }, - { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210 }, - { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171 }, - { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696 }, - { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322 }, - { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157 }, - { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330 }, - { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968 }, - { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311 }, - { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850 }, - { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210 }, - { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199 }, - { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848 }, - { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082 }, - { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866 }, - { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631 }, - { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254 }, - { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138 }, - { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398 }, - { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064 }, - { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680 }, - { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433 }, - { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181 }, - { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756 }, - { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092 }, - { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770 }, - { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562 }, - { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710 }, - { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205 }, - { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738 }, - { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888 }, - { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556 }, - { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899 }, - { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072 }, - { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886 }, - { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567 }, - { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372 }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306 }, - { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394 }, - { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343 }, - { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045 }, - { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024 }, - { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937 }, - { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844 }, - { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379 }, +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, ] [[package]] @@ -2764,18 +2764,18 @@ dependencies = [ { name = "rsa" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/7b/17244b1083e8e604bf154cf9b716aecd6388acd656dd01893d0d244c94d9/oauth2client-4.1.3.tar.gz", hash = "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6", size = 155910 } +sdist = { url = "https://files.pythonhosted.org/packages/a6/7b/17244b1083e8e604bf154cf9b716aecd6388acd656dd01893d0d244c94d9/oauth2client-4.1.3.tar.gz", hash = "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6", size = 155910, upload-time = "2018-09-07T21:38:18.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/a9/4f25a14d23f0786b64875b91784607c2277eff25d48f915e39ff0cff505a/oauth2client-4.1.3-py2.py3-none-any.whl", hash = "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac", size = 98206 }, + { url = "https://files.pythonhosted.org/packages/95/a9/4f25a14d23f0786b64875b91784607c2277eff25d48f915e39ff0cff505a/oauth2client-4.1.3-py2.py3-none-any.whl", hash = "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac", size = 98206, upload-time = "2018-09-07T21:38:16.742Z" }, ] [[package]] name = "oauthlib" version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] [[package]] @@ -2788,9 +2788,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/04/6dce2d581c54a8e55a3b128cf79a93821a68a62bb9a956e65476c5bb247e/office365_rest_python_client-2.6.2.tar.gz", hash = "sha256:ce27f5a1c0cc3ff97041ccd9b386145692be4c64739f243f7d6ac3edbe0a3c46", size = 659460 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/04/6dce2d581c54a8e55a3b128cf79a93821a68a62bb9a956e65476c5bb247e/office365_rest_python_client-2.6.2.tar.gz", hash = "sha256:ce27f5a1c0cc3ff97041ccd9b386145692be4c64739f243f7d6ac3edbe0a3c46", size = 659460, upload-time = "2025-05-11T10:24:21.895Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/a4/611155711f8af347875c15b8b83f5fd9e978bd4de45f90085b9a583b684d/Office365_REST_Python_Client-2.6.2-py3-none-any.whl", hash = "sha256:06fc6829c39b503897caa9d881db419d7f97a8e4f1c95c4c2d12db36ea6c955d", size = 1337139 }, + { url = "https://files.pythonhosted.org/packages/3a/a4/611155711f8af347875c15b8b83f5fd9e978bd4de45f90085b9a583b684d/Office365_REST_Python_Client-2.6.2-py3-none-any.whl", hash = "sha256:06fc6829c39b503897caa9d881db419d7f97a8e4f1c95c4c2d12db36ea6c955d", size = 1337139, upload-time = "2025-05-11T10:24:18.926Z" }, ] [[package]] @@ -2807,9 +2807,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717 } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122 }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] [[package]] @@ -2820,9 +2820,9 @@ dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767 } +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356 }, + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] [[package]] @@ -2834,9 +2834,9 @@ dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/77/f0b1f2bcf451ec5bc443d53bc7437577c3fc8444b3eb0d416ac5f7558b7b/opentelemetry_distro-0.60b1.tar.gz", hash = "sha256:8b7326b83a55ff7b17bb92225a86e2736a004f6af7aff00cb5d87b2d8e5bc283", size = 2584 } +sdist = { url = "https://files.pythonhosted.org/packages/15/77/f0b1f2bcf451ec5bc443d53bc7437577c3fc8444b3eb0d416ac5f7558b7b/opentelemetry_distro-0.60b1.tar.gz", hash = "sha256:8b7326b83a55ff7b17bb92225a86e2736a004f6af7aff00cb5d87b2d8e5bc283", size = 2584, upload-time = "2025-12-11T13:36:39.522Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/70/78a86531495040fcad9569d7daa630eca06d27d37c825a8aad448b7c1c5b/opentelemetry_distro-0.60b1-py3-none-any.whl", hash = "sha256:581104a786f5df252f4dfe725e0ae16337a26da902acb92d8b3e7aee29f0c76e", size = 3343 }, + { url = "https://files.pythonhosted.org/packages/24/70/78a86531495040fcad9569d7daa630eca06d27d37c825a8aad448b7c1c5b/opentelemetry_distro-0.60b1-py3-none-any.whl", hash = "sha256:581104a786f5df252f4dfe725e0ae16337a26da902acb92d8b3e7aee29f0c76e", size = 3343, upload-time = "2025-12-11T13:35:28.462Z" }, ] [[package]] @@ -2847,9 +2847,9 @@ dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/bd/abafe13a0d77145270a39de7442d12d71b51a9f9d103d15d636110ae8a21/opentelemetry_exporter_otlp-1.15.0.tar.gz", hash = "sha256:4f7c49751d9720e2e726e13b0bb958ccade4e29122c305d92c033da432c8d2c5", size = 6126 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/bd/abafe13a0d77145270a39de7442d12d71b51a9f9d103d15d636110ae8a21/opentelemetry_exporter_otlp-1.15.0.tar.gz", hash = "sha256:4f7c49751d9720e2e726e13b0bb958ccade4e29122c305d92c033da432c8d2c5", size = 6126, upload-time = "2022-12-09T22:28:43.353Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/a2/4956610bd5348977fea8818d488793a46d1359337c0226164f093a17c61c/opentelemetry_exporter_otlp-1.15.0-py3-none-any.whl", hash = "sha256:79f22748b6a54808a0448093dfa189c8490e729f67c134d4c992533d9393b33e", size = 6976 }, + { url = "https://files.pythonhosted.org/packages/64/a2/4956610bd5348977fea8818d488793a46d1359337c0226164f093a17c61c/opentelemetry_exporter_otlp-1.15.0-py3-none-any.whl", hash = "sha256:79f22748b6a54808a0448093dfa189c8490e729f67c134d4c992533d9393b33e", size = 6976, upload-time = "2022-12-09T22:28:12.944Z" }, ] [[package]] @@ -2864,9 +2864,9 @@ dependencies = [ { name = "opentelemetry-proto" }, { name = "opentelemetry-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/ab/1be294b194af410f350f867a54621b4f33b7551adce2ae795e907148fc1e/opentelemetry_exporter_otlp_proto_grpc-1.15.0.tar.gz", hash = "sha256:844f2a4bb9bcda34e4eb6fe36765e5031aacb36dc60ed88c90fc246942ea26e7", size = 27262 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/ab/1be294b194af410f350f867a54621b4f33b7551adce2ae795e907148fc1e/opentelemetry_exporter_otlp_proto_grpc-1.15.0.tar.gz", hash = "sha256:844f2a4bb9bcda34e4eb6fe36765e5031aacb36dc60ed88c90fc246942ea26e7", size = 27262, upload-time = "2022-12-09T22:28:44.359Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/8f/73ad108bcfd61b4169be5ad8b76acaf9158f224740da10ab9ea3469d551a/opentelemetry_exporter_otlp_proto_grpc-1.15.0-py3-none-any.whl", hash = "sha256:c2a5492ba7d140109968135d641d06ce3c5bd73c50665f787526065d57d7fd1d", size = 20378 }, + { url = "https://files.pythonhosted.org/packages/dd/8f/73ad108bcfd61b4169be5ad8b76acaf9158f224740da10ab9ea3469d551a/opentelemetry_exporter_otlp_proto_grpc-1.15.0-py3-none-any.whl", hash = "sha256:c2a5492ba7d140109968135d641d06ce3c5bd73c50665f787526065d57d7fd1d", size = 20378, upload-time = "2022-12-09T22:28:14.623Z" }, ] [[package]] @@ -2881,9 +2881,9 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/ee/14baa8edbf6b0c8e23a93ee0807fb637d4689959a0b166e2821032fade34/opentelemetry_exporter_otlp_proto_http-1.15.0.tar.gz", hash = "sha256:11b2c814249a49b22f6cca7a06b05701f561d577b747f3660dfd67b6eb9daf9c", size = 18930 } +sdist = { url = "https://files.pythonhosted.org/packages/ad/ee/14baa8edbf6b0c8e23a93ee0807fb637d4689959a0b166e2821032fade34/opentelemetry_exporter_otlp_proto_http-1.15.0.tar.gz", hash = "sha256:11b2c814249a49b22f6cca7a06b05701f561d577b747f3660dfd67b6eb9daf9c", size = 18930, upload-time = "2022-12-09T22:28:45.366Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/12/77af459682a4f41eb9f13801af6a12420a86f5673dc568585ee49112e969/opentelemetry_exporter_otlp_proto_http-1.15.0-py3-none-any.whl", hash = "sha256:3ec2a02196c8a54bf5cbf7fe623a5238625638e83b6047a983bdf96e2bbb74c0", size = 21588 }, + { url = "https://files.pythonhosted.org/packages/b0/12/77af459682a4f41eb9f13801af6a12420a86f5673dc568585ee49112e969/opentelemetry_exporter_otlp_proto_http-1.15.0-py3-none-any.whl", hash = "sha256:3ec2a02196c8a54bf5cbf7fe623a5238625638e83b6047a983bdf96e2bbb74c0", size = 21588, upload-time = "2022-12-09T22:28:15.776Z" }, ] [[package]] @@ -2896,9 +2896,9 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706 } +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096 }, + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, ] [[package]] @@ -2908,9 +2908,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/80/b3b2a98039574e57b6b15982219ae025d55f8c46d50dde258865ce5601b4/opentelemetry_proto-1.15.0.tar.gz", hash = "sha256:9c4008e40ac8cab359daac283fbe7002c5c29c77ea2674ad5626a249e64e0101", size = 35713 } +sdist = { url = "https://files.pythonhosted.org/packages/1e/80/b3b2a98039574e57b6b15982219ae025d55f8c46d50dde258865ce5601b4/opentelemetry_proto-1.15.0.tar.gz", hash = "sha256:9c4008e40ac8cab359daac283fbe7002c5c29c77ea2674ad5626a249e64e0101", size = 35713, upload-time = "2022-12-09T22:28:55.409Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/56/8343d94af8f32594f6b0bd273f72a40e430fb5970a353237af53af5d3031/opentelemetry_proto-1.15.0-py3-none-any.whl", hash = "sha256:044b6d044b4d10530f250856f933442b8753a17f94ae37c207607f733fb9a844", size = 52616 }, + { url = "https://files.pythonhosted.org/packages/3a/56/8343d94af8f32594f6b0bd273f72a40e430fb5970a353237af53af5d3031/opentelemetry_proto-1.15.0-py3-none-any.whl", hash = "sha256:044b6d044b4d10530f250856f933442b8753a17f94ae37c207607f733fb9a844", size = 52616, upload-time = "2022-12-09T22:28:30.03Z" }, ] [[package]] @@ -2922,9 +2922,9 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565 }, + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, ] [[package]] @@ -2935,9 +2935,9 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935 } +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982 }, + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, ] [[package]] @@ -2947,27 +2947,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/c7/12632c03022aa5059ce9b6738397cda682dfda9d9afe7008b8a4f98c6ee5/oracledb-2.4.0.tar.gz", hash = "sha256:bdd61a9d5077448b5f1c58af6a14accc287bf8032846c351a3cdde5cf64fe95b", size = 614809 } +sdist = { url = "https://files.pythonhosted.org/packages/ed/c7/12632c03022aa5059ce9b6738397cda682dfda9d9afe7008b8a4f98c6ee5/oracledb-2.4.0.tar.gz", hash = "sha256:bdd61a9d5077448b5f1c58af6a14accc287bf8032846c351a3cdde5cf64fe95b", size = 614809, upload-time = "2024-08-20T21:02:35.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/5b/5125e0a74a58717ac094d953ddaa4c61cfefcd926850c0ecc081e0c209f3/oracledb-2.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:03d1072db83e3f95a8792b8452c78940141902ef97f31223f1d96bfeb8ff830b", size = 3769983 }, - { url = "https://files.pythonhosted.org/packages/17/22/81eb81e15a86989acd21220480a87a3891a27b3f2d64b249098e09e002eb/oracledb-2.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fda77ace54379ad70187627ed02329f9ef4f35c1cc1052e4d27fe4ec68d38fc", size = 2081340 }, - { url = "https://files.pythonhosted.org/packages/6f/56/9cd84f67a573cc6066589d8264ab13f710a128197977205b9c4b177ee85e/oracledb-2.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bed34cdd5037277424bb5a38987e00cbb6eea3670ce9c4fcc3cab5971fab5348", size = 2234827 }, - { url = "https://files.pythonhosted.org/packages/f5/ca/4406cfe3400735bf4a1eee951eb174c6cd8573e74d43c1aba9448066a3d2/oracledb-2.4.0-cp312-cp312-win32.whl", hash = "sha256:02e1eea36de371d7719ca02d20a8900fab767e5db71aa59be101405060cf2cfa", size = 1373933 }, - { url = "https://files.pythonhosted.org/packages/a8/e9/1a8afdbe4aaba030476c91284d7599f54fce2879232d28797a4a71d5cfe2/oracledb-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:0b81ec1e20d4d20b0f95a673bb73923d24673e8739d3a25a746113519612c057", size = 1681666 }, - { url = "https://files.pythonhosted.org/packages/88/ae/603c592fc7054ccad523ba06f3d186cae5fb0f18ce477552be2178d6668b/oracledb-2.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b2933b4fd324da089a15567830d81d4ff1b3e7ecc24a615f9e61b0f7fcacf32d", size = 3730093 }, - { url = "https://files.pythonhosted.org/packages/af/70/744ab12e334375808678fbce494be560269f59dbda03613f02d4c22cadeb/oracledb-2.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d721d5fd0d45bd901bc76247172eb2e00f9feb67283dbb38e763e3e50308cb0", size = 2079861 }, - { url = "https://files.pythonhosted.org/packages/eb/5d/f1606491f05337d95e92ba8d474852d9616cc43bf24d60a64cc33a5f5517/oracledb-2.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23661da50934439b88fcedd9be3c8abecb313335abde9cf9faee3162c814744", size = 2235137 }, - { url = "https://files.pythonhosted.org/packages/ca/b0/cc05876b2a0b50a528dc5f01a81eb18386beeb0aba8993b796d1d381399e/oracledb-2.4.0-cp313-cp313-win32.whl", hash = "sha256:b10998a89fc93a31a968fd34d36547f7878f3efb3491e61493c78ddd5724283f", size = 1370670 }, - { url = "https://files.pythonhosted.org/packages/ee/09/bea4244b8e040f9a31178196082ffbde34404f8bb42c780a192b28a113b2/oracledb-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:e39779713558bc6f2e1ec78b71378536ec9da05dc5f95fe3ca41bfb6b878e81a", size = 1678689 }, + { url = "https://files.pythonhosted.org/packages/9e/5b/5125e0a74a58717ac094d953ddaa4c61cfefcd926850c0ecc081e0c209f3/oracledb-2.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:03d1072db83e3f95a8792b8452c78940141902ef97f31223f1d96bfeb8ff830b", size = 3769983, upload-time = "2024-08-20T21:03:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/17/22/81eb81e15a86989acd21220480a87a3891a27b3f2d64b249098e09e002eb/oracledb-2.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fda77ace54379ad70187627ed02329f9ef4f35c1cc1052e4d27fe4ec68d38fc", size = 2081340, upload-time = "2024-08-20T21:03:10.988Z" }, + { url = "https://files.pythonhosted.org/packages/6f/56/9cd84f67a573cc6066589d8264ab13f710a128197977205b9c4b177ee85e/oracledb-2.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bed34cdd5037277424bb5a38987e00cbb6eea3670ce9c4fcc3cab5971fab5348", size = 2234827, upload-time = "2024-08-20T21:03:13.716Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/4406cfe3400735bf4a1eee951eb174c6cd8573e74d43c1aba9448066a3d2/oracledb-2.4.0-cp312-cp312-win32.whl", hash = "sha256:02e1eea36de371d7719ca02d20a8900fab767e5db71aa59be101405060cf2cfa", size = 1373933, upload-time = "2024-08-20T21:03:15.514Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e9/1a8afdbe4aaba030476c91284d7599f54fce2879232d28797a4a71d5cfe2/oracledb-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:0b81ec1e20d4d20b0f95a673bb73923d24673e8739d3a25a746113519612c057", size = 1681666, upload-time = "2024-08-20T21:03:17.366Z" }, + { url = "https://files.pythonhosted.org/packages/88/ae/603c592fc7054ccad523ba06f3d186cae5fb0f18ce477552be2178d6668b/oracledb-2.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b2933b4fd324da089a15567830d81d4ff1b3e7ecc24a615f9e61b0f7fcacf32d", size = 3730093, upload-time = "2024-08-20T21:03:20.23Z" }, + { url = "https://files.pythonhosted.org/packages/af/70/744ab12e334375808678fbce494be560269f59dbda03613f02d4c22cadeb/oracledb-2.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d721d5fd0d45bd901bc76247172eb2e00f9feb67283dbb38e763e3e50308cb0", size = 2079861, upload-time = "2024-08-20T21:03:22.954Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/f1606491f05337d95e92ba8d474852d9616cc43bf24d60a64cc33a5f5517/oracledb-2.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23661da50934439b88fcedd9be3c8abecb313335abde9cf9faee3162c814744", size = 2235137, upload-time = "2024-08-20T21:03:25.061Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b0/cc05876b2a0b50a528dc5f01a81eb18386beeb0aba8993b796d1d381399e/oracledb-2.4.0-cp313-cp313-win32.whl", hash = "sha256:b10998a89fc93a31a968fd34d36547f7878f3efb3491e61493c78ddd5724283f", size = 1370670, upload-time = "2024-08-20T21:03:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/ee/09/bea4244b8e040f9a31178196082ffbde34404f8bb42c780a192b28a113b2/oracledb-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:e39779713558bc6f2e1ec78b71378536ec9da05dc5f95fe3ca41bfb6b878e81a", size = 1678689, upload-time = "2024-08-20T21:03:29.108Z" }, ] [[package]] name = "packaging" version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -2980,41 +2980,41 @@ dependencies = [ { name = "pytz" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846 }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618 }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212 }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693 }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002 }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971 }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722 }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671 }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807 }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872 }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371 }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333 }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120 }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991 }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227 }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056 }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189 }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912 }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160 }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233 }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635 }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079 }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049 }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638 }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834 }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925 }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071 }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504 }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702 }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535 }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582 }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963 }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175 }, +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] [[package]] @@ -3027,18 +3027,18 @@ dependencies = [ { name = "invoke" }, { name = "pynacl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586 } +sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919 }, + { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" }, ] [[package]] name = "pathspec" version = "1.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200 } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] @@ -3049,9 +3049,9 @@ dependencies = [ { name = "charset-normalizer" }, { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285 } +sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909 }, + { url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" }, ] [[package]] @@ -3063,9 +3063,9 @@ dependencies = [ { name = "pillow" }, { name = "pypdfium2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768 } +sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045 }, + { url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" }, ] [[package]] @@ -3075,78 +3075,78 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354 } +sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441 }, + { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" }, ] [[package]] name = "pillow" 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 } -wheels = [ - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489 }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129 }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896 }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266 }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592 }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542 }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723 }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400 }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084 }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152 }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579 }, +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/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" }, ] [[package]] @@ -3160,45 +3160,45 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3", marker = "python_full_version < '4'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/9d/07a7f2136ce04cabd21d69c057dc2915867082b0047e6873e424388d4475/pinecone-7.0.1.tar.gz", hash = "sha256:49ff7b0f5be4a2ddec5aaa709758a9f2df56baa58ad46507d081409e246a81ec", size = 207930 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/9d/07a7f2136ce04cabd21d69c057dc2915867082b0047e6873e424388d4475/pinecone-7.0.1.tar.gz", hash = "sha256:49ff7b0f5be4a2ddec5aaa709758a9f2df56baa58ad46507d081409e246a81ec", size = 207930, upload-time = "2025-05-21T19:39:01.218Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/88/896221e991077d353e61991b759f46d75f3b4298eb5a4aa6534c1371f4b0/pinecone-7.0.1-py3-none-any.whl", hash = "sha256:ce7b0dab3c9f7d81e75b24c13fcbca4a51371e08021faaecaf0cd9a45ca1be6c", size = 516590 }, + { url = "https://files.pythonhosted.org/packages/81/88/896221e991077d353e61991b759f46d75f3b4298eb5a4aa6534c1371f4b0/pinecone-7.0.1-py3-none-any.whl", hash = "sha256:ce7b0dab3c9f7d81e75b24c13fcbca4a51371e08021faaecaf0cd9a45ca1be6c", size = 516590, upload-time = "2025-05-21T19:38:59.117Z" }, ] [[package]] name = "pinecone-plugin-interface" version = "0.0.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/fb/e8a4063264953ead9e2b24d9b390152c60f042c951c47f4592e9996e57ff/pinecone_plugin_interface-0.0.7.tar.gz", hash = "sha256:b8e6675e41847333aa13923cc44daa3f85676d7157324682dc1640588a982846", size = 3370 } +sdist = { url = "https://files.pythonhosted.org/packages/f4/fb/e8a4063264953ead9e2b24d9b390152c60f042c951c47f4592e9996e57ff/pinecone_plugin_interface-0.0.7.tar.gz", hash = "sha256:b8e6675e41847333aa13923cc44daa3f85676d7157324682dc1640588a982846", size = 3370, upload-time = "2024-06-05T01:57:52.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/1d/a21fdfcd6d022cb64cef5c2a29ee6691c6c103c4566b41646b080b7536a5/pinecone_plugin_interface-0.0.7-py3-none-any.whl", hash = "sha256:875857ad9c9fc8bbc074dbe780d187a2afd21f5bfe0f3b08601924a61ef1bba8", size = 6249 }, + { url = "https://files.pythonhosted.org/packages/3b/1d/a21fdfcd6d022cb64cef5c2a29ee6691c6c103c4566b41646b080b7536a5/pinecone_plugin_interface-0.0.7-py3-none-any.whl", hash = "sha256:875857ad9c9fc8bbc074dbe780d187a2afd21f5bfe0f3b08601924a61ef1bba8", size = 6249, upload-time = "2024-06-05T01:57:50.583Z" }, ] [[package]] name = "platformdirs" version = "4.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168 }, + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "ply" version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130 } +sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567 }, + { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, ] [[package]] @@ -3208,18 +3208,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pywin32", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 }, + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, ] [[package]] name = "prometheus-client" version = "0.24.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616 } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057 }, + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, ] [[package]] @@ -3229,93 +3229,93 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] name = "propcache" version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061 }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037 }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324 }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505 }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242 }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474 }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575 }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736 }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019 }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376 }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988 }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615 }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066 }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655 }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789 }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750 }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780 }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308 }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182 }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215 }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112 }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442 }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398 }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920 }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748 }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877 }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437 }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586 }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790 }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158 }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451 }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374 }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396 }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950 }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856 }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420 }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254 }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205 }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873 }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739 }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514 }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781 }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396 }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897 }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789 }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152 }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869 }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596 }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981 }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490 }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371 }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424 }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566 }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130 }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625 }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209 }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797 }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140 }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257 }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097 }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455 }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372 }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411 }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712 }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557 }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015 }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880 }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938 }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641 }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510 }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161 }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393 }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546 }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259 }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428 }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] [[package]] @@ -3325,109 +3325,109 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480 }, + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, ] [[package]] name = "protobuf" version = "4.25.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/01/34c8d2b6354906d728703cb9d546a0e534de479e25f1b581e4094c4a85cc/protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd", size = 380920 } +sdist = { url = "https://files.pythonhosted.org/packages/df/01/34c8d2b6354906d728703cb9d546a0e534de479e25f1b581e4094c4a85cc/protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd", size = 380920, upload-time = "2025-05-28T14:22:25.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ff/05f34305fe6b85bbfbecbc559d423a5985605cad5eda4f47eae9e9c9c5c5/protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0", size = 392745 }, - { url = "https://files.pythonhosted.org/packages/08/35/8b8a8405c564caf4ba835b1fdf554da869954712b26d8f2a98c0e434469b/protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9", size = 413736 }, - { url = "https://files.pythonhosted.org/packages/28/d7/ab27049a035b258dab43445eb6ec84a26277b16105b277cbe0a7698bdc6c/protobuf-4.25.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ca809b42f4444f144f2115c4c1a747b9a404d590f18f37e9402422033e464e0f", size = 394537 }, - { url = "https://files.pythonhosted.org/packages/bd/6d/a4a198b61808dd3d1ee187082ccc21499bc949d639feb948961b48be9a7e/protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7", size = 294005 }, - { url = "https://files.pythonhosted.org/packages/d6/c6/c9deaa6e789b6fc41b88ccbdfe7a42d2b82663248b715f55aa77fbc00724/protobuf-4.25.8-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:83e6e54e93d2b696a92cad6e6efc924f3850f82b52e1563778dfab8b355101b0", size = 294924 }, - { url = "https://files.pythonhosted.org/packages/0c/c1/6aece0ab5209981a70cd186f164c133fdba2f51e124ff92b73de7fd24d78/protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59", size = 156757 }, + { url = "https://files.pythonhosted.org/packages/45/ff/05f34305fe6b85bbfbecbc559d423a5985605cad5eda4f47eae9e9c9c5c5/protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0", size = 392745, upload-time = "2025-05-28T14:22:10.524Z" }, + { url = "https://files.pythonhosted.org/packages/08/35/8b8a8405c564caf4ba835b1fdf554da869954712b26d8f2a98c0e434469b/protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9", size = 413736, upload-time = "2025-05-28T14:22:13.156Z" }, + { url = "https://files.pythonhosted.org/packages/28/d7/ab27049a035b258dab43445eb6ec84a26277b16105b277cbe0a7698bdc6c/protobuf-4.25.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ca809b42f4444f144f2115c4c1a747b9a404d590f18f37e9402422033e464e0f", size = 394537, upload-time = "2025-05-28T14:22:14.768Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6d/a4a198b61808dd3d1ee187082ccc21499bc949d639feb948961b48be9a7e/protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7", size = 294005, upload-time = "2025-05-28T14:22:16.052Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c6/c9deaa6e789b6fc41b88ccbdfe7a42d2b82663248b715f55aa77fbc00724/protobuf-4.25.8-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:83e6e54e93d2b696a92cad6e6efc924f3850f82b52e1563778dfab8b355101b0", size = 294924, upload-time = "2025-05-28T14:22:17.105Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c1/6aece0ab5209981a70cd186f164c133fdba2f51e124ff92b73de7fd24d78/protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59", size = 156757, upload-time = "2025-05-28T14:22:24.135Z" }, ] [[package]] name = "psutil" version = "5.9.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/c7/6dc0a455d111f68ee43f27793971cf03fe29b6ef972042549db29eec39a2/psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c", size = 503247 } +sdist = { url = "https://files.pythonhosted.org/packages/90/c7/6dc0a455d111f68ee43f27793971cf03fe29b6ef972042549db29eec39a2/psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c", size = 503247, upload-time = "2024-01-19T20:47:09.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e3/07ae864a636d70a8a6f58da27cb1179192f1140d5d1da10886ade9405797/psutil-5.9.8-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81", size = 248702 }, - { url = "https://files.pythonhosted.org/packages/b3/bd/28c5f553667116b2598b9cc55908ec435cb7f77a34f2bff3e3ca765b0f78/psutil-5.9.8-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421", size = 285242 }, - { url = "https://files.pythonhosted.org/packages/c5/4f/0e22aaa246f96d6ac87fe5ebb9c5a693fbe8877f537a1022527c47ca43c5/psutil-5.9.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4", size = 288191 }, - { url = "https://files.pythonhosted.org/packages/6e/f5/2aa3a4acdc1e5940b59d421742356f133185667dd190b166dbcfcf5d7b43/psutil-5.9.8-cp37-abi3-win32.whl", hash = "sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0", size = 251252 }, - { url = "https://files.pythonhosted.org/packages/93/52/3e39d26feae7df0aa0fd510b14012c3678b36ed068f7d78b8d8784d61f0e/psutil-5.9.8-cp37-abi3-win_amd64.whl", hash = "sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf", size = 255090 }, - { url = "https://files.pythonhosted.org/packages/05/33/2d74d588408caedd065c2497bdb5ef83ce6082db01289a1e1147f6639802/psutil-5.9.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8", size = 249898 }, + { url = "https://files.pythonhosted.org/packages/e7/e3/07ae864a636d70a8a6f58da27cb1179192f1140d5d1da10886ade9405797/psutil-5.9.8-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81", size = 248702, upload-time = "2024-01-19T20:47:36.303Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bd/28c5f553667116b2598b9cc55908ec435cb7f77a34f2bff3e3ca765b0f78/psutil-5.9.8-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421", size = 285242, upload-time = "2024-01-19T20:47:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4f/0e22aaa246f96d6ac87fe5ebb9c5a693fbe8877f537a1022527c47ca43c5/psutil-5.9.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4", size = 288191, upload-time = "2024-01-19T20:47:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/2aa3a4acdc1e5940b59d421742356f133185667dd190b166dbcfcf5d7b43/psutil-5.9.8-cp37-abi3-win32.whl", hash = "sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0", size = 251252, upload-time = "2024-01-19T20:47:52.88Z" }, + { url = "https://files.pythonhosted.org/packages/93/52/3e39d26feae7df0aa0fd510b14012c3678b36ed068f7d78b8d8784d61f0e/psutil-5.9.8-cp37-abi3-win_amd64.whl", hash = "sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf", size = 255090, upload-time = "2024-01-19T20:47:56.019Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/2d74d588408caedd065c2497bdb5ef83ce6082db01289a1e1147f6639802/psutil-5.9.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8", size = 249898, upload-time = "2024-01-19T20:47:59.238Z" }, ] [[package]] name = "psycopg2-binary" version = "2.9.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/07/e720e53bfab016ebcc34241695ccc06a9e3d91ba19b40ca81317afbdc440/psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c", size = 384973 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/07/e720e53bfab016ebcc34241695ccc06a9e3d91ba19b40ca81317afbdc440/psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c", size = 384973, upload-time = "2023-10-03T12:48:55.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/d0/5f2db14e7b53552276ab613399a83f83f85b173a862d3f20580bc7231139/psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf", size = 2823784 }, - { url = "https://files.pythonhosted.org/packages/18/ca/da384fd47233e300e3e485c90e7aab5d7def896d1281239f75901faf87d4/psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d", size = 2553308 }, - { url = "https://files.pythonhosted.org/packages/50/66/fa53d2d3d92f6e1ef469d92afc6a4fe3f6e8a9a04b687aa28fb1f1d954ee/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212", size = 2851283 }, - { url = "https://files.pythonhosted.org/packages/04/37/2429360ac5547378202db14eec0dde76edbe1f6627df5a43c7e164922859/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493", size = 3081839 }, - { url = "https://files.pythonhosted.org/packages/62/2a/c0530b59d7e0d09824bc2102ecdcec0456b8ca4d47c0caa82e86fce3ed4c/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996", size = 3264488 }, - { url = "https://files.pythonhosted.org/packages/19/57/9f172b900795ea37246c78b5f52e00f4779984370855b3e161600156906d/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119", size = 3020700 }, - { url = "https://files.pythonhosted.org/packages/94/68/1176fc14ea76861b7b8360be5176e87fb20d5091b137c76570eb4e237324/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba", size = 2355968 }, - { url = "https://files.pythonhosted.org/packages/70/bb/aec2646a705a09079d008ce88073401cd61fc9b04f92af3eb282caa3a2ec/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07", size = 2536101 }, - { url = "https://files.pythonhosted.org/packages/14/33/12818c157e333cb9d9e6753d1b2463b6f60dbc1fade115f8e4dc5c52cac4/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb", size = 2487064 }, - { url = "https://files.pythonhosted.org/packages/56/a2/7851c68fe8768f3c9c246198b6356ee3e4a8a7f6820cc798443faada3400/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe", size = 2456257 }, - { url = "https://files.pythonhosted.org/packages/6f/ee/3ba07c6dc7c3294e717e94720da1597aedc82a10b1b180203ce183d4631a/psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93", size = 1024709 }, - { url = "https://files.pythonhosted.org/packages/7b/08/9c66c269b0d417a0af9fb969535f0371b8c538633535a7a6a5ca3f9231e2/psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", size = 1163864 }, + { url = "https://files.pythonhosted.org/packages/a7/d0/5f2db14e7b53552276ab613399a83f83f85b173a862d3f20580bc7231139/psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf", size = 2823784, upload-time = "2023-10-03T12:47:00.404Z" }, + { url = "https://files.pythonhosted.org/packages/18/ca/da384fd47233e300e3e485c90e7aab5d7def896d1281239f75901faf87d4/psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d", size = 2553308, upload-time = "2023-11-01T10:40:33.984Z" }, + { url = "https://files.pythonhosted.org/packages/50/66/fa53d2d3d92f6e1ef469d92afc6a4fe3f6e8a9a04b687aa28fb1f1d954ee/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212", size = 2851283, upload-time = "2023-10-03T12:47:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/2429360ac5547378202db14eec0dde76edbe1f6627df5a43c7e164922859/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493", size = 3081839, upload-time = "2023-10-03T12:47:05.027Z" }, + { url = "https://files.pythonhosted.org/packages/62/2a/c0530b59d7e0d09824bc2102ecdcec0456b8ca4d47c0caa82e86fce3ed4c/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996", size = 3264488, upload-time = "2023-10-03T12:47:08.962Z" }, + { url = "https://files.pythonhosted.org/packages/19/57/9f172b900795ea37246c78b5f52e00f4779984370855b3e161600156906d/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119", size = 3020700, upload-time = "2023-10-03T12:47:12.23Z" }, + { url = "https://files.pythonhosted.org/packages/94/68/1176fc14ea76861b7b8360be5176e87fb20d5091b137c76570eb4e237324/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba", size = 2355968, upload-time = "2023-10-03T12:47:14.817Z" }, + { url = "https://files.pythonhosted.org/packages/70/bb/aec2646a705a09079d008ce88073401cd61fc9b04f92af3eb282caa3a2ec/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07", size = 2536101, upload-time = "2023-10-03T12:47:17.454Z" }, + { url = "https://files.pythonhosted.org/packages/14/33/12818c157e333cb9d9e6753d1b2463b6f60dbc1fade115f8e4dc5c52cac4/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb", size = 2487064, upload-time = "2023-10-03T12:47:20.717Z" }, + { url = "https://files.pythonhosted.org/packages/56/a2/7851c68fe8768f3c9c246198b6356ee3e4a8a7f6820cc798443faada3400/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe", size = 2456257, upload-time = "2023-10-03T12:47:23.004Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ee/3ba07c6dc7c3294e717e94720da1597aedc82a10b1b180203ce183d4631a/psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93", size = 1024709, upload-time = "2023-10-28T09:37:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/7b/08/9c66c269b0d417a0af9fb969535f0371b8c538633535a7a6a5ca3f9231e2/psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", size = 1163864, upload-time = "2023-10-28T09:37:28.155Z" }, ] [[package]] name = "pyarrow" version = "24.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559 }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654 }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394 }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122 }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032 }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490 }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660 }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759 }, - { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471 }, - { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981 }, - { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172 }, - { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733 }, - { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335 }, - { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748 }, - { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554 }, - { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301 }, - { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929 }, - { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365 }, - { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819 }, - { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252 }, - { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127 }, - { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997 }, - { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720 }, - { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852 }, - { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852 }, - { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207 }, - { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117 }, - { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155 }, - { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387 }, - { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102 }, - { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118 }, - { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765 }, - { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890 }, - { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250 }, - { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282 }, +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] [[package]] name = "pyasn1" version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685 } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997 }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] @@ -3437,27 +3437,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] name = "pycodestyle" version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472 } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594 }, + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] [[package]] name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -3470,9 +3470,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] @@ -3482,68 +3482,68 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] [[package]] @@ -3555,9 +3555,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253 } +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413 }, + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] [[package]] @@ -3570,9 +3570,9 @@ dependencies = [ { name = "pyopenssl" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/74/b591079fa588351cec61861b85ba26f7deb96f3b445556c100e17db5572b/PyDrive2-1.15.4.tar.gz", hash = "sha256:0c011b74ebc24f3c6ca72820626b77f1dfe0ae88f5740c5a5cf96e83dd79ba99", size = 60514 } +sdist = { url = "https://files.pythonhosted.org/packages/45/74/b591079fa588351cec61861b85ba26f7deb96f3b445556c100e17db5572b/PyDrive2-1.15.4.tar.gz", hash = "sha256:0c011b74ebc24f3c6ca72820626b77f1dfe0ae88f5740c5a5cf96e83dd79ba99", size = 60514, upload-time = "2023-05-21T02:25:57.217Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/f4/d0b40ee1c703304e8cc737e53516f834c0fbad4fe9b27aed7680d9fdf344/PyDrive2-1.15.4-py3-none-any.whl", hash = "sha256:91fe28e5f094a6dfff834495c4aee0041cbef979467ad27cd0d4b1f91afa8869", size = 45011 }, + { url = "https://files.pythonhosted.org/packages/18/f4/d0b40ee1c703304e8cc737e53516f834c0fbad4fe9b27aed7680d9fdf344/PyDrive2-1.15.4-py3-none-any.whl", hash = "sha256:91fe28e5f094a6dfff834495c4aee0041cbef979467ad27cd0d4b1f91afa8869", size = 45011, upload-time = "2023-05-21T02:25:55.265Z" }, ] [package.optional-dependencies] @@ -3587,27 +3587,27 @@ fsspec = [ name = "pyflakes" version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669 } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551 }, + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyjwt" version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -3628,47 +3628,47 @@ dependencies = [ { name = "setuptools" }, { name = "ujson" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/13/899185f025802ba80255faa8e45b3f3bf9cb7bab2d4235e12e3322c8e2a4/pymilvus-2.5.18.tar.gz", hash = "sha256:9e517076068e98dac51c018bc0dfe1f651d936154e2e2d9ad6c7b3dab1164e2d", size = 1285482 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/13/899185f025802ba80255faa8e45b3f3bf9cb7bab2d4235e12e3322c8e2a4/pymilvus-2.5.18.tar.gz", hash = "sha256:9e517076068e98dac51c018bc0dfe1f651d936154e2e2d9ad6c7b3dab1164e2d", size = 1285482, upload-time = "2025-12-02T10:58:25.399Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/9c/a2b50b2b369814003460ca12a3c195fbf11b89bc1a861c2aa737c33ad7f9/pymilvus-2.5.18-py3-none-any.whl", hash = "sha256:1b78badcfa8d62db7d0b29193fc0422e4676873ff1c745a9d75c2c885d7a7e32", size = 244089 }, + { url = "https://files.pythonhosted.org/packages/c0/9c/a2b50b2b369814003460ca12a3c195fbf11b89bc1a861c2aa737c33ad7f9/pymilvus-2.5.18-py3-none-any.whl", hash = "sha256:1b78badcfa8d62db7d0b29193fc0422e4676873ff1c745a9d75c2c885d7a7e32", size = 244089, upload-time = "2025-12-02T10:58:23.944Z" }, ] [[package]] name = "pymssql" version = "2.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/35/5a0b79369e42fffd5c04e4e74fa90ef034cc5c3f314e14f6d58cac646ccf/pymssql-2.3.4.tar.gz", hash = "sha256:117c82d7aa9021171aa9be98368475519f33d9c32073cdcf9b0d76231abc6436", size = 184604 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/89/5a7a4b27ee44b2dc4708de7e897311cb17f15e7c983c299e8bf97ebf98d1/pymssql-2.3.4-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:809b75aaeb9bcd061230bace41e275f80f464f70fcbf5dde2ba7ba8f0eea5298", size = 3075736 }, - { url = "https://files.pythonhosted.org/packages/43/f9/19bbb0026a47043fb239e821e10a75304b12ba986ce4af71cf8986af411c/pymssql-2.3.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ab1ee04754fb8ce703b6c154e54fde4f6c7f440766d397b101b748123a12df", size = 4019433 }, - { url = "https://files.pythonhosted.org/packages/a6/ac/3aca13f1f527299db4adef594fb9f14d47d68de91b93a220a67391b8ec87/pymssql-2.3.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e22bb4d5aed85b084e3b9fb5ae3463301dd69c17703cfef72e0aed746452cc9", size = 3993550 }, - { url = "https://files.pythonhosted.org/packages/b9/93/879d92f61afb974f69b9186b16ee6a97adff2abc82777e3b66c9c9efb179/pymssql-2.3.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2c1c8d3632630d52387e5b5b4483027494b5cb8f65401573715b74e7a3f16e5", size = 4381934 }, - { url = "https://files.pythonhosted.org/packages/6c/a6/923769b6dbb4e3a4c07a867e0c7fa8e4b230f675095cd7109d4e3eb9ddf0/pymssql-2.3.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f7f245acbdf89b96a41513ef0214b55a3ba2824f1f3119dd1945443b6cac78d3", size = 4849674 }, - { url = "https://files.pythonhosted.org/packages/7a/2d/c787f061dcd0603905bf8085dda9cddb8c3c03b18d9239d5d18c953eebba/pymssql-2.3.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9484485fb847eb67f828459b0f4857c9725b20c517c2b7f88a9788fd72b76a6a", size = 4076649 }, - { url = "https://files.pythonhosted.org/packages/c1/a2/e55d823e3ab21cf9fc88e4e2424936899392d9d2e6569d5bcce063f84dac/pymssql-2.3.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4a0716482cd5ecce07230925593cefd9137959c18aca4c92fc24c243d3c20e38", size = 4139477 }, - { url = "https://files.pythonhosted.org/packages/c7/7c/0fec6587b38081d0d0fca4f9ad31e85ec6c5791879e57f0e559ec6be4d3d/pymssql-2.3.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ba4f988674b361709821c8173a6471aa6e47ee6e45b5a8e30d4dcbde1f62fb0f", size = 4653837 }, - { url = "https://files.pythonhosted.org/packages/5f/7c/77d0251f4b5ad5690226a93547fc8279c1c48bd14e3ccc820f5c580a3b73/pymssql-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:51b8ebfbd7d14d5e7c65e76ffaf31584ffabe9fb1bfd2a85f529bd707512e39d", size = 4910914 }, - { url = "https://files.pythonhosted.org/packages/4f/22/1b2ef85804872a5940010d3c012722356af1fa24f8ba6f419c0260881032/pymssql-2.3.4-cp312-cp312-win32.whl", hash = "sha256:c8f5718f5e7d2623eaf35e025d5fa288c5789916809a89f00b42346b888673da", size = 1337991 }, - { url = "https://files.pythonhosted.org/packages/0f/43/c98f34e7b3cd45653fb233a4bee83bffca0cf5e78c290c291cec34faac21/pymssql-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:d72b38b5ba66a4072c680447099bb63ac35d0425e9a29ff91b048e563b999be5", size = 2021760 }, - { url = "https://files.pythonhosted.org/packages/63/58/90dbe299359c547fcb037d4a12f2146916213b99a245d01efdf5ade52910/pymssql-2.3.4-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:36ede0bc046e18cb0a5f043828bc441c80ffb2aa4606ce0cfcbf2a3d71266f0a", size = 3064581 }, - { url = "https://files.pythonhosted.org/packages/4b/7c/15e75a74de5e392ea1a9456261632cc312c873f28ac2f9ef39dfefac8cd2/pymssql-2.3.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d247114853ada387415df303d24d2e990596ce28b23f5b59c46d852cfea0f2ad", size = 4013283 }, - { url = "https://files.pythonhosted.org/packages/2a/29/b9f08676145c3086db11c55b40bd58dfb0d775853f7280c1b2e15fc44fc2/pymssql-2.3.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79881cbe1a5826ddb959ccf8add015e5b82e6afbbf9cf5e281bd794278b2c2eb", size = 3996475 }, - { url = "https://files.pythonhosted.org/packages/ab/cb/54ca973c666e8402f3bf7feaf7e2037b7c80dbd732be67e224f95cb6a1cc/pymssql-2.3.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4bfcd63280b0f74124241092bdfd7889925342bcb58b4cde299e4c91cec55436", size = 4377615 }, - { url = "https://files.pythonhosted.org/packages/c1/f2/973dfded45e0df9dcf72bc1b7254cefd5ffb1492f314822020d3c066421f/pymssql-2.3.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f3b784563f2b24c4d3f0e250fa9cfe59a22791539725f4d5059139c66f072a14", size = 4839647 }, - { url = "https://files.pythonhosted.org/packages/91/cb/9d9342f0936ff6d58a59446e7449f93cc1134e59f3a1ec075e7b364e82a6/pymssql-2.3.4-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a54a018215cf0cffbaaa6edaa02215ef19fa9c9ff6a2c172e8fa563f577e2e91", size = 4079413 }, - { url = "https://files.pythonhosted.org/packages/9e/f1/79866247539144dcc9e44e9f8ad700bdc78c286863f37d879d71bbfd2c94/pymssql-2.3.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:14f2474fda3c57bc95b9ba146552463571fe77c816cbfb2e64344528d9afb755", size = 4141187 }, - { url = "https://files.pythonhosted.org/packages/9c/2d/c187ebcaeb2832cc7ac85034897eb920b361fd63bf011a5d02b31fe2f840/pymssql-2.3.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:145dc2b73e4fe115e6176866245921ce95a216a8d6cb0d9420c2e05ee2a911a9", size = 4661965 }, - { url = "https://files.pythonhosted.org/packages/77/59/aae5ba396d1c603325112bf7106705e1781e4604381faa45ad55161f2b0f/pymssql-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e08f1bc9e4a914c82816e3e5270b53bead13d3444435fc7bddfff9cb302b9982", size = 4903978 }, - { url = "https://files.pythonhosted.org/packages/3f/a9/25ea7056857aabbfd285c397084c571e4486f341ff8e8086b067bc2e2109/pymssql-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e31b507f4669671e8bbdeecf1c1c2ed9c092953a1decfae5af656200a74195d1", size = 1337662 }, +sdist = { url = "https://files.pythonhosted.org/packages/83/35/5a0b79369e42fffd5c04e4e74fa90ef034cc5c3f314e14f6d58cac646ccf/pymssql-2.3.4.tar.gz", hash = "sha256:117c82d7aa9021171aa9be98368475519f33d9c32073cdcf9b0d76231abc6436", size = 184604, upload-time = "2025-04-02T02:08:43.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/89/5a7a4b27ee44b2dc4708de7e897311cb17f15e7c983c299e8bf97ebf98d1/pymssql-2.3.4-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:809b75aaeb9bcd061230bace41e275f80f464f70fcbf5dde2ba7ba8f0eea5298", size = 3075736, upload-time = "2025-04-02T02:11:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/43/f9/19bbb0026a47043fb239e821e10a75304b12ba986ce4af71cf8986af411c/pymssql-2.3.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ab1ee04754fb8ce703b6c154e54fde4f6c7f440766d397b101b748123a12df", size = 4019433, upload-time = "2025-04-02T03:07:58.222Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ac/3aca13f1f527299db4adef594fb9f14d47d68de91b93a220a67391b8ec87/pymssql-2.3.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e22bb4d5aed85b084e3b9fb5ae3463301dd69c17703cfef72e0aed746452cc9", size = 3993550, upload-time = "2025-04-02T02:13:16.433Z" }, + { url = "https://files.pythonhosted.org/packages/b9/93/879d92f61afb974f69b9186b16ee6a97adff2abc82777e3b66c9c9efb179/pymssql-2.3.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2c1c8d3632630d52387e5b5b4483027494b5cb8f65401573715b74e7a3f16e5", size = 4381934, upload-time = "2025-04-02T02:12:45.424Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a6/923769b6dbb4e3a4c07a867e0c7fa8e4b230f675095cd7109d4e3eb9ddf0/pymssql-2.3.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f7f245acbdf89b96a41513ef0214b55a3ba2824f1f3119dd1945443b6cac78d3", size = 4849674, upload-time = "2025-04-02T02:13:05.245Z" }, + { url = "https://files.pythonhosted.org/packages/7a/2d/c787f061dcd0603905bf8085dda9cddb8c3c03b18d9239d5d18c953eebba/pymssql-2.3.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9484485fb847eb67f828459b0f4857c9725b20c517c2b7f88a9788fd72b76a6a", size = 4076649, upload-time = "2025-04-02T02:15:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a2/e55d823e3ab21cf9fc88e4e2424936899392d9d2e6569d5bcce063f84dac/pymssql-2.3.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4a0716482cd5ecce07230925593cefd9137959c18aca4c92fc24c243d3c20e38", size = 4139477, upload-time = "2025-04-02T02:13:42.91Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/0fec6587b38081d0d0fca4f9ad31e85ec6c5791879e57f0e559ec6be4d3d/pymssql-2.3.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ba4f988674b361709821c8173a6471aa6e47ee6e45b5a8e30d4dcbde1f62fb0f", size = 4653837, upload-time = "2025-04-02T02:15:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7c/77d0251f4b5ad5690226a93547fc8279c1c48bd14e3ccc820f5c580a3b73/pymssql-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:51b8ebfbd7d14d5e7c65e76ffaf31584ffabe9fb1bfd2a85f529bd707512e39d", size = 4910914, upload-time = "2025-04-02T02:13:55.446Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/1b2ef85804872a5940010d3c012722356af1fa24f8ba6f419c0260881032/pymssql-2.3.4-cp312-cp312-win32.whl", hash = "sha256:c8f5718f5e7d2623eaf35e025d5fa288c5789916809a89f00b42346b888673da", size = 1337991, upload-time = "2025-04-02T02:29:43.394Z" }, + { url = "https://files.pythonhosted.org/packages/0f/43/c98f34e7b3cd45653fb233a4bee83bffca0cf5e78c290c291cec34faac21/pymssql-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:d72b38b5ba66a4072c680447099bb63ac35d0425e9a29ff91b048e563b999be5", size = 2021760, upload-time = "2025-04-02T02:28:06.757Z" }, + { url = "https://files.pythonhosted.org/packages/63/58/90dbe299359c547fcb037d4a12f2146916213b99a245d01efdf5ade52910/pymssql-2.3.4-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:36ede0bc046e18cb0a5f043828bc441c80ffb2aa4606ce0cfcbf2a3d71266f0a", size = 3064581, upload-time = "2025-04-02T02:09:43.911Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7c/15e75a74de5e392ea1a9456261632cc312c873f28ac2f9ef39dfefac8cd2/pymssql-2.3.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d247114853ada387415df303d24d2e990596ce28b23f5b59c46d852cfea0f2ad", size = 4013283, upload-time = "2025-04-02T03:08:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/29/b9f08676145c3086db11c55b40bd58dfb0d775853f7280c1b2e15fc44fc2/pymssql-2.3.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79881cbe1a5826ddb959ccf8add015e5b82e6afbbf9cf5e281bd794278b2c2eb", size = 3996475, upload-time = "2025-04-02T02:13:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cb/54ca973c666e8402f3bf7feaf7e2037b7c80dbd732be67e224f95cb6a1cc/pymssql-2.3.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4bfcd63280b0f74124241092bdfd7889925342bcb58b4cde299e4c91cec55436", size = 4377615, upload-time = "2025-04-02T02:12:46.677Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f2/973dfded45e0df9dcf72bc1b7254cefd5ffb1492f314822020d3c066421f/pymssql-2.3.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f3b784563f2b24c4d3f0e250fa9cfe59a22791539725f4d5059139c66f072a14", size = 4839647, upload-time = "2025-04-02T02:13:07.216Z" }, + { url = "https://files.pythonhosted.org/packages/91/cb/9d9342f0936ff6d58a59446e7449f93cc1134e59f3a1ec075e7b364e82a6/pymssql-2.3.4-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a54a018215cf0cffbaaa6edaa02215ef19fa9c9ff6a2c172e8fa563f577e2e91", size = 4079413, upload-time = "2025-04-02T02:15:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f1/79866247539144dcc9e44e9f8ad700bdc78c286863f37d879d71bbfd2c94/pymssql-2.3.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:14f2474fda3c57bc95b9ba146552463571fe77c816cbfb2e64344528d9afb755", size = 4141187, upload-time = "2025-04-02T02:13:44.711Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2d/c187ebcaeb2832cc7ac85034897eb920b361fd63bf011a5d02b31fe2f840/pymssql-2.3.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:145dc2b73e4fe115e6176866245921ce95a216a8d6cb0d9420c2e05ee2a911a9", size = 4661965, upload-time = "2025-04-02T02:15:06.727Z" }, + { url = "https://files.pythonhosted.org/packages/77/59/aae5ba396d1c603325112bf7106705e1781e4604381faa45ad55161f2b0f/pymssql-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e08f1bc9e4a914c82816e3e5270b53bead13d3444435fc7bddfff9cb302b9982", size = 4903978, upload-time = "2025-04-02T02:13:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/25ea7056857aabbfd285c397084c571e4486f341ff8e8086b067bc2e2109/pymssql-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e31b507f4669671e8bbdeecf1c1c2ed9c092953a1decfae5af656200a74195d1", size = 1337662, upload-time = "2025-04-02T02:21:12.84Z" }, ] [[package]] name = "pymysql" version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/ce59b5e5ed4ce8512f879ff1fa5ab699d211ae2495f1adaa5fbba2a1eada/pymysql-1.1.1.tar.gz", hash = "sha256:e127611aaf2b417403c60bf4dc570124aeb4a57f5f37b8e95ae399a42f904cd0", size = 47678 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/ce59b5e5ed4ce8512f879ff1fa5ab699d211ae2495f1adaa5fbba2a1eada/pymysql-1.1.1.tar.gz", hash = "sha256:e127611aaf2b417403c60bf4dc570124aeb4a57f5f37b8e95ae399a42f904cd0", size = 47678, upload-time = "2024-05-21T11:03:43.722Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/94/e4181a1f6286f545507528c78016e00065ea913276888db2262507693ce5/PyMySQL-1.1.1-py3-none-any.whl", hash = "sha256:4de15da4c61dc132f4fb9ab763063e693d521a80fd0e87943b9a453dd4c19d6c", size = 44972 }, + { url = "https://files.pythonhosted.org/packages/0c/94/e4181a1f6286f545507528c78016e00065ea913276888db2262507693ce5/PyMySQL-1.1.1-py3-none-any.whl", hash = "sha256:4de15da4c61dc132f4fb9ab763063e693d521a80fd0e87943b9a453dd4c19d6c", size = 44972, upload-time = "2024-05-21T11:03:41.216Z" }, ] [[package]] @@ -3678,17 +3678,17 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920 }, - { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722 }, - { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087 }, - { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678 }, - { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660 }, - { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824 }, - { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912 }, - { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624 }, - { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141 }, + { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" }, + { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" }, ] [[package]] @@ -3699,56 +3699,56 @@ dependencies = [ { name = "cryptography" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195 } +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823 }, + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, ] [[package]] name = "pyparsing" version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781 }, + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] [[package]] name = "pypdf" version = "6.13.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/18/9947cc201af9ccf76720fd3347bf4f70eb882ce3fcf4cb05f7443e4cf871/pypdf-6.13.3.tar.gz", hash = "sha256:f3cb822769725f1bac658c406cfc9460399043f3750c2d3e4650e0a85eacabd7", size = 6484063 } +sdist = { url = "https://files.pythonhosted.org/packages/17/18/9947cc201af9ccf76720fd3347bf4f70eb882ce3fcf4cb05f7443e4cf871/pypdf-6.13.3.tar.gz", hash = "sha256:f3cb822769725f1bac658c406cfc9460399043f3750c2d3e4650e0a85eacabd7", size = 6484063, upload-time = "2026-06-17T15:22:00.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/56/2967e621598987905fb8cdfadd8f8de6b5c68c9351f0523c4df8409f28f1/pypdf-6.13.3-py3-none-any.whl", hash = "sha256:c6e3f86afb625791510b02ad5480e94b63970bb957df75d44657c282ecc52224", size = 347288 }, + { url = "https://files.pythonhosted.org/packages/94/56/2967e621598987905fb8cdfadd8f8de6b5c68c9351f0523c4df8409f28f1/pypdf-6.13.3-py3-none-any.whl", hash = "sha256:c6e3f86afb625791510b02ad5480e94b63970bb957df75d44657c282ecc52224", size = 347288, upload-time = "2026-06-17T15:21:59.512Z" }, ] [[package]] name = "pypdfium2" version = "5.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/f6/42f5f1b9beb7e036f5532832b9c590fd107c52a78f704302c03bc6793954/pypdfium2-5.5.0.tar.gz", hash = "sha256:3283c61f54c3c546d140da201ef48a51c18b0ad54293091a010029ac13ece23a", size = 270502 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/c0/cdddce35108c118cc110c1c2ed16de82d74d7646b9bcf98eae2fa440966b/pypdfium2-5.5.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:414f0b4aef7413e04df7355043fb752f2efb6f9777e04fd880d302612dacf89f", size = 2760984 }, - { url = "https://files.pythonhosted.org/packages/d0/c7/23a6fbd6d23fd8dbe657696acd81fba858639ef221254ce05970152ad1d8/pypdfium2-5.5.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:126ff8b131d12f16ce96b3e85b7f413e5073212be06b571f157fe11ad221c274", size = 2303146 }, - { url = "https://files.pythonhosted.org/packages/bc/a9/379ec56c4481f39f0e37a7ce42f4844e6ddd7662571922e2b348105960ab/pypdfium2-5.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0770bd3f0be5c68443fc4017e43b1b1fe8f36877481cab70fd29b68b2c362e1b", size = 2815036 }, - { url = "https://files.pythonhosted.org/packages/91/a4/b0cc01aaae1fdf1ca4e080cc55bb432f5a2234f33209a602bc498a47850d/pypdfium2-5.5.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:5ab41a3b9953d9be44be35c36a2340f1d67c602db98a0d6f70006610871ae43a", size = 2948686 }, - { url = "https://files.pythonhosted.org/packages/26/99/25a0c71b551d100b505c618910afec0df402b230e087078c8078f8b1fcff/pypdfium2-5.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2492a22c3126a004cee2fa208ea4aa03ede2c7e205d05814934ab18f83d073e9", size = 2977311 }, - { url = "https://files.pythonhosted.org/packages/85/64/691e21539566f7a0521295948b5589d2fdfe3df5acab9c29ff410633a839/pypdfium2-5.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83ff93e08b1fadb00040564e2eccc99147fc1a632ba5daff745126b373d78446", size = 2762449 }, - { url = "https://files.pythonhosted.org/packages/74/b1/9af288557291e2964bf5ffd460b7ed1090fcb8c54addfd6c7c5deb9ba7c7/pypdfium2-5.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7e85de3332bedf8e5f157c248063b4eaf968660e1e490353b6e581d9f96a4c6", size = 3074851 }, - { url = "https://files.pythonhosted.org/packages/a4/1e/c61fddbdea5ea1ba478dc7ecc9d68069d17b858e5fed04e4e071811f0858/pypdfium2-5.5.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e258365f34b6e334bb415e44dd9b1ee78a6e525bf854a1e74af67af7ede7555b", size = 3423003 }, - { url = "https://files.pythonhosted.org/packages/36/5f/d2eb58c54abba3a6c3bc4c297b3a11348dd4b4deb073f1aa8a872a298278/pypdfium2-5.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bec21d833404ca771f02fa5cefb0b73e2148f05cbdb3b5b9989bdd51d9b5cbac", size = 3002104 }, - { url = "https://files.pythonhosted.org/packages/1c/33/87423eec4f5d4287d5a1726dbb9f06fb1f1aebc38ff75dcff817c492769d/pypdfium2-5.5.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1dd6ccbe1b5e2e778e8b021e47f9485b4fd42eaa6c9bdda2631641724e1fcc04", size = 3097209 }, - { url = "https://files.pythonhosted.org/packages/97/0a/a3fd71f00838bba7922691107219bee67f50fbda6d12df330ef485a97848/pypdfium2-5.5.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da3eada345570cec5e34872d1472d4ac542f0e650ccdb6c2eac08ae1a5f07c82", size = 2965027 }, - { url = "https://files.pythonhosted.org/packages/75/4a/2181260bd8a0b1b30ac50b7fd6ee3366e04f3a9f1c29351d882652da7fa7/pypdfium2-5.5.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a087fb4088c7433fd3d78833dbe42cfb66df3d5ac98e3edf66110520fb33c0f0", size = 4131431 }, - { url = "https://files.pythonhosted.org/packages/15/bb/3ccf481191346eda11c0c208bd4e46f8de019ae7d9e9c1b660633f0bb3f4/pypdfium2-5.5.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6418cdc500ef85a90319f9bc7f1c54fc133460379f509429403225d8a4c157f", size = 3747468 }, - { url = "https://files.pythonhosted.org/packages/15/51/17e50ec72cf2235ac18d9cbe907859501c769d3e964818fefac6a3e10727/pypdfium2-5.5.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8f7b66eedfac26eb2df4b00936e081b0a1c76fb8ee1c12639d85c2e73b0769ef", size = 4337579 }, - { url = "https://files.pythonhosted.org/packages/c6/e4/f9bdf06f4d3f1e56eff9d997392a00a4b66cbc9c20f33934c4edc2a7943f/pypdfium2-5.5.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:faea3246591ce2ea6218cd06679071275e3c65f11c3f5c9091eb7fb07610af6a", size = 4376104 }, - { url = "https://files.pythonhosted.org/packages/8c/20/06baf1f5d494e035f50fc895fa1da5ed652d03ecc59aeb3aabb0daa5adfc/pypdfium2-5.5.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:aba26d404b51a9de3d3e80c867a95c71abf1c79552001ae22707451e59186b3d", size = 3929824 }, - { url = "https://files.pythonhosted.org/packages/3a/01/28940e54e6936674e9a05eb58ccce7c54d8e2ac81cd84ec0b76e7d32a010/pypdfium2-5.5.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:e0fa8f81679e6e71f26806f4db853571ee6435dc3bde7a46acdd182ef886a5b9", size = 4270200 }, - { url = "https://files.pythonhosted.org/packages/cb/d4/1f36c505a3770aad9a88c895a46d61fd4c0535f79548f02c93b97ff89604/pypdfium2-5.5.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ee22df3376d350eeb64d2002a1071e3a02c0d874c557a3cd8229a8fc572cdaac", size = 4180794 }, - { url = "https://files.pythonhosted.org/packages/ac/38/f77e7792b4fba37f0e3d78db52fb7288d41db3c46ed28906fb940bc3e325/pypdfium2-5.5.0-py3-none-win32.whl", hash = "sha256:ec62a00223d1222d2f35c0866dd79cdc24da070738544cdf51b17d332d4a7389", size = 3001772 }, - { url = "https://files.pythonhosted.org/packages/3e/c5/0d7ba53148262f78d8eee528a504764f78ae7bebf434a53714294b1fd973/pypdfium2-5.5.0-py3-none-win_amd64.whl", hash = "sha256:15c32fbeebb5198afa785dd03e98906ebb4eded9ef8862e10f833c37b4a18786", size = 3107710 }, - { url = "https://files.pythonhosted.org/packages/29/ad/fae449d2ed7b3088c6ab088f53fc6a9e9af26ccc9e0477d4182e373c4dd8/pypdfium2-5.5.0-py3-none-win_arm64.whl", hash = "sha256:f618af0884c16c768539c44933a255039131dbbf39d68eded020da4f14958d73", size = 2938315 }, +sdist = { url = "https://files.pythonhosted.org/packages/fb/f6/42f5f1b9beb7e036f5532832b9c590fd107c52a78f704302c03bc6793954/pypdfium2-5.5.0.tar.gz", hash = "sha256:3283c61f54c3c546d140da201ef48a51c18b0ad54293091a010029ac13ece23a", size = 270502, upload-time = "2026-02-18T23:22:37.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/c0/cdddce35108c118cc110c1c2ed16de82d74d7646b9bcf98eae2fa440966b/pypdfium2-5.5.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:414f0b4aef7413e04df7355043fb752f2efb6f9777e04fd880d302612dacf89f", size = 2760984, upload-time = "2026-02-18T23:21:56.668Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c7/23a6fbd6d23fd8dbe657696acd81fba858639ef221254ce05970152ad1d8/pypdfium2-5.5.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:126ff8b131d12f16ce96b3e85b7f413e5073212be06b571f157fe11ad221c274", size = 2303146, upload-time = "2026-02-18T23:21:58.466Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a9/379ec56c4481f39f0e37a7ce42f4844e6ddd7662571922e2b348105960ab/pypdfium2-5.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0770bd3f0be5c68443fc4017e43b1b1fe8f36877481cab70fd29b68b2c362e1b", size = 2815036, upload-time = "2026-02-18T23:22:00.288Z" }, + { url = "https://files.pythonhosted.org/packages/91/a4/b0cc01aaae1fdf1ca4e080cc55bb432f5a2234f33209a602bc498a47850d/pypdfium2-5.5.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:5ab41a3b9953d9be44be35c36a2340f1d67c602db98a0d6f70006610871ae43a", size = 2948686, upload-time = "2026-02-18T23:22:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/26/99/25a0c71b551d100b505c618910afec0df402b230e087078c8078f8b1fcff/pypdfium2-5.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2492a22c3126a004cee2fa208ea4aa03ede2c7e205d05814934ab18f83d073e9", size = 2977311, upload-time = "2026-02-18T23:22:03.603Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/691e21539566f7a0521295948b5589d2fdfe3df5acab9c29ff410633a839/pypdfium2-5.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83ff93e08b1fadb00040564e2eccc99147fc1a632ba5daff745126b373d78446", size = 2762449, upload-time = "2026-02-18T23:22:05.044Z" }, + { url = "https://files.pythonhosted.org/packages/74/b1/9af288557291e2964bf5ffd460b7ed1090fcb8c54addfd6c7c5deb9ba7c7/pypdfium2-5.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7e85de3332bedf8e5f157c248063b4eaf968660e1e490353b6e581d9f96a4c6", size = 3074851, upload-time = "2026-02-18T23:22:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/c61fddbdea5ea1ba478dc7ecc9d68069d17b858e5fed04e4e071811f0858/pypdfium2-5.5.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e258365f34b6e334bb415e44dd9b1ee78a6e525bf854a1e74af67af7ede7555b", size = 3423003, upload-time = "2026-02-18T23:22:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/d2eb58c54abba3a6c3bc4c297b3a11348dd4b4deb073f1aa8a872a298278/pypdfium2-5.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bec21d833404ca771f02fa5cefb0b73e2148f05cbdb3b5b9989bdd51d9b5cbac", size = 3002104, upload-time = "2026-02-18T23:22:12.035Z" }, + { url = "https://files.pythonhosted.org/packages/1c/33/87423eec4f5d4287d5a1726dbb9f06fb1f1aebc38ff75dcff817c492769d/pypdfium2-5.5.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1dd6ccbe1b5e2e778e8b021e47f9485b4fd42eaa6c9bdda2631641724e1fcc04", size = 3097209, upload-time = "2026-02-18T23:22:13.809Z" }, + { url = "https://files.pythonhosted.org/packages/97/0a/a3fd71f00838bba7922691107219bee67f50fbda6d12df330ef485a97848/pypdfium2-5.5.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da3eada345570cec5e34872d1472d4ac542f0e650ccdb6c2eac08ae1a5f07c82", size = 2965027, upload-time = "2026-02-18T23:22:16.324Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/2181260bd8a0b1b30ac50b7fd6ee3366e04f3a9f1c29351d882652da7fa7/pypdfium2-5.5.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a087fb4088c7433fd3d78833dbe42cfb66df3d5ac98e3edf66110520fb33c0f0", size = 4131431, upload-time = "2026-02-18T23:22:18.469Z" }, + { url = "https://files.pythonhosted.org/packages/15/bb/3ccf481191346eda11c0c208bd4e46f8de019ae7d9e9c1b660633f0bb3f4/pypdfium2-5.5.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6418cdc500ef85a90319f9bc7f1c54fc133460379f509429403225d8a4c157f", size = 3747468, upload-time = "2026-02-18T23:22:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/51/17e50ec72cf2235ac18d9cbe907859501c769d3e964818fefac6a3e10727/pypdfium2-5.5.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8f7b66eedfac26eb2df4b00936e081b0a1c76fb8ee1c12639d85c2e73b0769ef", size = 4337579, upload-time = "2026-02-18T23:22:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e4/f9bdf06f4d3f1e56eff9d997392a00a4b66cbc9c20f33934c4edc2a7943f/pypdfium2-5.5.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:faea3246591ce2ea6218cd06679071275e3c65f11c3f5c9091eb7fb07610af6a", size = 4376104, upload-time = "2026-02-18T23:22:25.337Z" }, + { url = "https://files.pythonhosted.org/packages/8c/20/06baf1f5d494e035f50fc895fa1da5ed652d03ecc59aeb3aabb0daa5adfc/pypdfium2-5.5.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:aba26d404b51a9de3d3e80c867a95c71abf1c79552001ae22707451e59186b3d", size = 3929824, upload-time = "2026-02-18T23:22:26.889Z" }, + { url = "https://files.pythonhosted.org/packages/3a/01/28940e54e6936674e9a05eb58ccce7c54d8e2ac81cd84ec0b76e7d32a010/pypdfium2-5.5.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:e0fa8f81679e6e71f26806f4db853571ee6435dc3bde7a46acdd182ef886a5b9", size = 4270200, upload-time = "2026-02-18T23:22:28.668Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d4/1f36c505a3770aad9a88c895a46d61fd4c0535f79548f02c93b97ff89604/pypdfium2-5.5.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ee22df3376d350eeb64d2002a1071e3a02c0d874c557a3cd8229a8fc572cdaac", size = 4180794, upload-time = "2026-02-18T23:22:30.11Z" }, + { url = "https://files.pythonhosted.org/packages/ac/38/f77e7792b4fba37f0e3d78db52fb7288d41db3c46ed28906fb940bc3e325/pypdfium2-5.5.0-py3-none-win32.whl", hash = "sha256:ec62a00223d1222d2f35c0866dd79cdc24da070738544cdf51b17d332d4a7389", size = 3001772, upload-time = "2026-02-18T23:22:32.367Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c5/0d7ba53148262f78d8eee528a504764f78ae7bebf434a53714294b1fd973/pypdfium2-5.5.0-py3-none-win_amd64.whl", hash = "sha256:15c32fbeebb5198afa785dd03e98906ebb4eded9ef8862e10f833c37b4a18786", size = 3107710, upload-time = "2026-02-18T23:22:33.925Z" }, + { url = "https://files.pythonhosted.org/packages/29/ad/fae449d2ed7b3088c6ab088f53fc6a9e9af26ccc9e0477d4182e373c4dd8/pypdfium2-5.5.0-py3-none-win_arm64.whl", hash = "sha256:f618af0884c16c768539c44933a255039131dbbf39d68eded020da4f14958d73", size = 2938315, upload-time = "2026-02-18T23:22:35.907Z" }, ] [[package]] @@ -3762,9 +3762,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -3775,9 +3775,9 @@ dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] @@ -3789,9 +3789,9 @@ dependencies = [ { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] @@ -3801,9 +3801,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] [[package]] @@ -3813,9 +3813,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 }, + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] [[package]] @@ -3825,18 +3825,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -3846,18 +3846,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "simple-websocket" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348 } +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847 }, + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, ] [[package]] name = "python-magic" version = "0.4.27" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677 } +sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840 }, + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, ] [[package]] @@ -3868,47 +3868,47 @@ dependencies = [ { name = "bidict" }, { name = "python-engineio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508 } +sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054 }, + { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, ] [[package]] name = "pytokens" version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663 }, - { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626 }, - { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779 }, - { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076 }, - { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552 }, - { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720 }, - { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204 }, - { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423 }, - { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859 }, - { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520 }, - { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821 }, - { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263 }, - { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071 }, - { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716 }, - { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539 }, - { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474 }, - { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473 }, - { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485 }, - { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698 }, - { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287 }, - { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729 }, +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] [[package]] name = "pytz" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] [[package]] @@ -3916,61 +3916,61 @@ name = "pywin32" version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543 }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040 }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102 }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700 }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700 }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318 }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714 }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800 }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -3986,18 +3986,18 @@ dependencies = [ { name = "pydantic" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/7d/3cd10e26ae97b35cf856ca1dc67576e42414ae39502c51165bb36bb1dff8/qdrant_client-1.16.2.tar.gz", hash = "sha256:ca4ef5f9be7b5eadeec89a085d96d5c723585a391eb8b2be8192919ab63185f0", size = 331112 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/7d/3cd10e26ae97b35cf856ca1dc67576e42414ae39502c51165bb36bb1dff8/qdrant_client-1.16.2.tar.gz", hash = "sha256:ca4ef5f9be7b5eadeec89a085d96d5c723585a391eb8b2be8192919ab63185f0", size = 331112, upload-time = "2025-12-12T10:58:30.866Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186 }, + { url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186, upload-time = "2025-12-12T10:58:29.282Z" }, ] [[package]] name = "redis" version = "5.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355 } +sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355, upload-time = "2024-12-06T09:50:41.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502 }, + { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502, upload-time = "2024-12-06T09:50:39.656Z" }, ] [[package]] @@ -4009,97 +4009,97 @@ dependencies = [ { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "regex" version = "2026.2.28" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574 }, - { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426 }, - { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200 }, - { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765 }, - { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093 }, - { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455 }, - { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037 }, - { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113 }, - { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194 }, - { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846 }, - { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516 }, - { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278 }, - { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068 }, - { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416 }, - { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297 }, - { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408 }, - { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311 }, - { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285 }, - { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051 }, - { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842 }, - { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083 }, - { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412 }, - { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101 }, - { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260 }, - { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311 }, - { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876 }, - { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632 }, - { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320 }, - { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152 }, - { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398 }, - { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282 }, - { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382 }, - { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541 }, - { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984 }, - { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509 }, - { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429 }, - { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422 }, - { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175 }, - { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044 }, - { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056 }, - { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743 }, - { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633 }, - { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862 }, - { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788 }, - { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184 }, - { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137 }, - { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682 }, - { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735 }, - { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497 }, - { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295 }, - { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275 }, - { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176 }, - { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813 }, - { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678 }, - { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528 }, - { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373 }, - { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859 }, - { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813 }, - { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705 }, - { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734 }, - { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871 }, - { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825 }, - { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548 }, - { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444 }, - { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546 }, - { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986 }, - { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518 }, - { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464 }, - { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553 }, - { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289 }, - { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156 }, - { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215 }, - { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925 }, - { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701 }, - { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899 }, - { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727 }, - { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366 }, - { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936 }, - { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779 }, - { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010 }, +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, + { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" }, + { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" }, + { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" }, + { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" }, + { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" }, + { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" }, + { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" }, + { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" }, + { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, + { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, + { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, + { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, + { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" }, ] [[package]] @@ -4112,9 +4112,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] @@ -4125,9 +4125,9 @@ dependencies = [ { name = "oauthlib" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] [[package]] @@ -4137,9 +4137,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] [[package]] @@ -4151,9 +4151,9 @@ dependencies = [ { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303 } +sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099 }, + { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" }, ] [[package]] @@ -4164,90 +4164,90 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053 }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763 }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951 }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622 }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492 }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080 }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680 }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589 }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289 }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737 }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120 }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 }, +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] [[package]] @@ -4257,9 +4257,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 } +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] [[package]] @@ -4271,9 +4271,9 @@ dependencies = [ { name = "aiohttp" }, { name = "fsspec" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/65/4b4c868cff76c036d11dc75dd91e5696dbf16ce626514166f35d5f4a930f/s3fs-2024.10.0.tar.gz", hash = "sha256:58b8c3650f8b99dbedf361543da3533aac8707035a104db5d80b094617ad4a3f", size = 75916 } +sdist = { url = "https://files.pythonhosted.org/packages/75/65/4b4c868cff76c036d11dc75dd91e5696dbf16ce626514166f35d5f4a930f/s3fs-2024.10.0.tar.gz", hash = "sha256:58b8c3650f8b99dbedf361543da3533aac8707035a104db5d80b094617ad4a3f", size = 75916, upload-time = "2024-10-21T01:45:49.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/44/bb9ff095ae7b1b6908480f683b6ca6b71c2105d343a5e5cb25334b01f5fa/s3fs-2024.10.0-py3-none-any.whl", hash = "sha256:7a2025d60d5b1a6025726b3a5e292a8e5aa713abc3b16fd1f81735181f7bb282", size = 29855 }, + { url = "https://files.pythonhosted.org/packages/99/44/bb9ff095ae7b1b6908480f683b6ca6b71c2105d343a5e5cb25334b01f5fa/s3fs-2024.10.0-py3-none-any.whl", hash = "sha256:7a2025d60d5b1a6025726b3a5e292a8e5aa713abc3b16fd1f81735181f7bb282", size = 29855, upload-time = "2024-10-21T01:45:47.905Z" }, ] [package.optional-dependencies] @@ -4288,27 +4288,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287, upload-time = "2024-11-20T21:06:05.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175 }, + { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175, upload-time = "2024-11-20T21:06:03.961Z" }, ] [[package]] name = "setuptools" version = "82.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893 } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468 }, + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] @@ -4318,33 +4318,33 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wsproto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842 }, + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, ] [[package]] name = "singleton-decorator" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/98/a8b5c919bee1152a9a1afd82014431f8db5882699754de50d1b3aba4d136/singleton-decorator-1.0.0.tar.gz", hash = "sha256:1a90ad8a8a738be591c9c167fdd677c5d4a43d1bc6b1c128227be1c5e03bee07", size = 2791 } +sdist = { url = "https://files.pythonhosted.org/packages/33/98/a8b5c919bee1152a9a1afd82014431f8db5882699754de50d1b3aba4d136/singleton-decorator-1.0.0.tar.gz", hash = "sha256:1a90ad8a8a738be591c9c167fdd677c5d4a43d1bc6b1c128227be1c5e03bee07", size = 2791, upload-time = "2017-08-10T19:52:45.903Z" } [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] @@ -4370,23 +4370,23 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/4d/7e6a9088381386b4cfae4c5d1d23ea0c3618ca694bc2290118737af59f36/snowflake_connector_python-4.6.0.tar.gz", hash = "sha256:06e2dba02703da6fd60e07bb0574506f810a85e5831d3461247753ecce4b8335", size = 937999 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/4e/a839eddf87df7fe91fd8086e6a43e10e6afddf7c6b718ef036643f032867/snowflake_connector_python-4.6.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a7701b702dbeb348769c5d1248231e18544c4ff1fb4118ad73d48e8f801cfb6e", size = 1167890 }, - { url = "https://files.pythonhosted.org/packages/7d/81/632b4ca9459cd801abfaa5396a60d9e60b9e2f051d015a577af0493782d3/snowflake_connector_python-4.6.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:00abbcfe958f60da18297191f3499b1e61802e64622521a2e8da1c059c14e1c0", size = 1181169 }, - { url = "https://files.pythonhosted.org/packages/c9/31/79871d7eea206c60a7891a8d4349fdd8933822101af87204231162a5c3e8/snowflake_connector_python-4.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:72aaee21a70e00fbe4dadcc60b9b1012b6411dddc90f94804d5efe5706fb9621", size = 2878875 }, - { url = "https://files.pythonhosted.org/packages/e5/ff/ea43b9f87cf632bd9735f4da18d7982572fb67073fd55c67841091a20f1a/snowflake_connector_python-4.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d3f6120edeb0d6edd208831d006cc3e769ec51bc346727f22d7aeaecbf20f77", size = 2910491 }, - { url = "https://files.pythonhosted.org/packages/52/b1/80bc142ce5afee2e9b0520e4444bcdf1a02627c1066653705e4c36b475ab/snowflake_connector_python-4.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:f15e2493a316ce79ab3d7fb16add10252bb2401723e5cfbc7a2ebc44d89a7b2b", size = 5388193 }, - { url = "https://files.pythonhosted.org/packages/cd/7b/29af48b122f5df4e2c23a1733bd5ed28193f24734a7cf48e345e5c7c3012/snowflake_connector_python-4.6.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e0ca5a035b1afa690fb36a767ba59c8db85ef6295b88c2bbc2040449e99992ad", size = 1166660 }, - { url = "https://files.pythonhosted.org/packages/20/af/9c5f1551278a309bbda06662e842b34fc17a60916032e5402033482c0367/snowflake_connector_python-4.6.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:1894504c69a76ac4a205d01fbb3e18c6a6e974e6ad26dad263edd06343bea501", size = 1179744 }, - { url = "https://files.pythonhosted.org/packages/4e/ef/fdaf6150dacf80edd4dac948fd9a08930944d2ad2e978fe33aca598aa0a5/snowflake_connector_python-4.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ed40d1e9d867253596860b9d5240280489ff4692b7a3fa21e2d45d63b4b61d36", size = 2844736 }, - { url = "https://files.pythonhosted.org/packages/da/a1/25fdb592dfed3150b429f1bbb22b495c2590e5a5007153be9d1b798c72c9/snowflake_connector_python-4.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c8476781cfef961fc5f6f75a5238e668d3e0ca5ebf1d055661b2fcf2831c254", size = 2878174 }, - { url = "https://files.pythonhosted.org/packages/29/1f/081d2fb06fca926bb2e9af81533516af4f86ca13abe2b7cbb16ee4938339/snowflake_connector_python-4.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8ccbf8b5e12177a86bd3ab8292cc5a99e9ac97d7645ef4a3ed0f767b4ec6594", size = 5388257 }, - { url = "https://files.pythonhosted.org/packages/31/db/4de9e9c82082441c09abad9d7fe30170c8101ecdfb012affab0383401fe2/snowflake_connector_python-4.6.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1fe93d88278a0b7e0efde6140890bc298a49fbf1e04968a35aa22c801131cced", size = 1167356 }, - { url = "https://files.pythonhosted.org/packages/3f/05/3a946e69712c178b0de355971c49e1b2afd259b8dc3992c5f8898214f9fd/snowflake_connector_python-4.6.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0829d57467bf1bb5af411f6e7723058cb2218fb7df07cf15d912e3b1a2c126eb", size = 1179787 }, - { url = "https://files.pythonhosted.org/packages/4d/c6/2b367aa04fb6f6d8e6da22908dd8f61f49ba613306648a2b35b61fb70cd4/snowflake_connector_python-4.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:676162cd45df744aa966483960d34bf204cdcae87cecad77fba970f1c2fd570d", size = 2845398 }, - { url = "https://files.pythonhosted.org/packages/b7/94/ec61dfbad2d70131c46605a52487733bc98e2d7b26ddd32334f1c4db104d/snowflake_connector_python-4.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eab420406a38ebc059100bb1faa55d7d6306bb224cefadb739ec3cafeff65384", size = 2874335 }, - { url = "https://files.pythonhosted.org/packages/af/de/0a816b9877948f60071a5852b1b97a4605475fce4704f04f89d7ca9f43f2/snowflake_connector_python-4.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:9dd8689123a7e7b873db0846f2d92745a02062b16665d20634fbaf34a9c88e7a", size = 5446341 }, +sdist = { url = "https://files.pythonhosted.org/packages/49/4d/7e6a9088381386b4cfae4c5d1d23ea0c3618ca694bc2290118737af59f36/snowflake_connector_python-4.6.0.tar.gz", hash = "sha256:06e2dba02703da6fd60e07bb0574506f810a85e5831d3461247753ecce4b8335", size = 937999, upload-time = "2026-05-28T13:01:48.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/4e/a839eddf87df7fe91fd8086e6a43e10e6afddf7c6b718ef036643f032867/snowflake_connector_python-4.6.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a7701b702dbeb348769c5d1248231e18544c4ff1fb4118ad73d48e8f801cfb6e", size = 1167890, upload-time = "2026-05-28T13:01:56.567Z" }, + { url = "https://files.pythonhosted.org/packages/7d/81/632b4ca9459cd801abfaa5396a60d9e60b9e2f051d015a577af0493782d3/snowflake_connector_python-4.6.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:00abbcfe958f60da18297191f3499b1e61802e64622521a2e8da1c059c14e1c0", size = 1181169, upload-time = "2026-05-28T13:01:58.16Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/79871d7eea206c60a7891a8d4349fdd8933822101af87204231162a5c3e8/snowflake_connector_python-4.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:72aaee21a70e00fbe4dadcc60b9b1012b6411dddc90f94804d5efe5706fb9621", size = 2878875, upload-time = "2026-05-28T13:01:36.26Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ff/ea43b9f87cf632bd9735f4da18d7982572fb67073fd55c67841091a20f1a/snowflake_connector_python-4.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d3f6120edeb0d6edd208831d006cc3e769ec51bc346727f22d7aeaecbf20f77", size = 2910491, upload-time = "2026-05-28T13:01:37.957Z" }, + { url = "https://files.pythonhosted.org/packages/52/b1/80bc142ce5afee2e9b0520e4444bcdf1a02627c1066653705e4c36b475ab/snowflake_connector_python-4.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:f15e2493a316ce79ab3d7fb16add10252bb2401723e5cfbc7a2ebc44d89a7b2b", size = 5388193, upload-time = "2026-05-28T13:02:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7b/29af48b122f5df4e2c23a1733bd5ed28193f24734a7cf48e345e5c7c3012/snowflake_connector_python-4.6.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e0ca5a035b1afa690fb36a767ba59c8db85ef6295b88c2bbc2040449e99992ad", size = 1166660, upload-time = "2026-05-28T13:01:59.64Z" }, + { url = "https://files.pythonhosted.org/packages/20/af/9c5f1551278a309bbda06662e842b34fc17a60916032e5402033482c0367/snowflake_connector_python-4.6.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:1894504c69a76ac4a205d01fbb3e18c6a6e974e6ad26dad263edd06343bea501", size = 1179744, upload-time = "2026-05-28T13:02:01.254Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/fdaf6150dacf80edd4dac948fd9a08930944d2ad2e978fe33aca598aa0a5/snowflake_connector_python-4.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ed40d1e9d867253596860b9d5240280489ff4692b7a3fa21e2d45d63b4b61d36", size = 2844736, upload-time = "2026-05-28T13:01:40.001Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/25fdb592dfed3150b429f1bbb22b495c2590e5a5007153be9d1b798c72c9/snowflake_connector_python-4.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c8476781cfef961fc5f6f75a5238e668d3e0ca5ebf1d055661b2fcf2831c254", size = 2878174, upload-time = "2026-05-28T13:01:42.448Z" }, + { url = "https://files.pythonhosted.org/packages/29/1f/081d2fb06fca926bb2e9af81533516af4f86ca13abe2b7cbb16ee4938339/snowflake_connector_python-4.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8ccbf8b5e12177a86bd3ab8292cc5a99e9ac97d7645ef4a3ed0f767b4ec6594", size = 5388257, upload-time = "2026-05-28T13:02:13.073Z" }, + { url = "https://files.pythonhosted.org/packages/31/db/4de9e9c82082441c09abad9d7fe30170c8101ecdfb012affab0383401fe2/snowflake_connector_python-4.6.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1fe93d88278a0b7e0efde6140890bc298a49fbf1e04968a35aa22c801131cced", size = 1167356, upload-time = "2026-05-28T13:02:02.706Z" }, + { url = "https://files.pythonhosted.org/packages/3f/05/3a946e69712c178b0de355971c49e1b2afd259b8dc3992c5f8898214f9fd/snowflake_connector_python-4.6.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0829d57467bf1bb5af411f6e7723058cb2218fb7df07cf15d912e3b1a2c126eb", size = 1179787, upload-time = "2026-05-28T13:02:04.387Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c6/2b367aa04fb6f6d8e6da22908dd8f61f49ba613306648a2b35b61fb70cd4/snowflake_connector_python-4.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:676162cd45df744aa966483960d34bf204cdcae87cecad77fba970f1c2fd570d", size = 2845398, upload-time = "2026-05-28T13:01:44.538Z" }, + { url = "https://files.pythonhosted.org/packages/b7/94/ec61dfbad2d70131c46605a52487733bc98e2d7b26ddd32334f1c4db104d/snowflake_connector_python-4.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eab420406a38ebc059100bb1faa55d7d6306bb224cefadb739ec3cafeff65384", size = 2874335, upload-time = "2026-05-28T13:01:46.742Z" }, + { url = "https://files.pythonhosted.org/packages/af/de/0a816b9877948f60071a5852b1b97a4605475fce4704f04f89d7ca9f43f2/snowflake_connector_python-4.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:9dd8689123a7e7b873db0846f2d92745a02062b16665d20634fbaf34a9c88e7a", size = 5446341, upload-time = "2026-05-28T13:02:16.985Z" }, ] [package.optional-dependencies] @@ -4399,18 +4399,18 @@ pandas = [ name = "sortedcontainers" version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] name = "soupsieve" version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016 }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] @@ -4421,42 +4421,42 @@ dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/80/88/74eb470223ff88ea6572a132c0b8de8c1d8ed7b843d3b44a8a3c77f31d39/sqlalchemy-2.0.47-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fa91b19d6b9821c04cc8f7aa2476429cc8887b9687c762815aa629f5c0edec1", size = 2155687 }, - { url = "https://files.pythonhosted.org/packages/ef/ba/1447d3d558971b036cb93b557595cb5dcdfe728f1c7ac4dec16505ef5756/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c5bbbd14eff577c8c79cbfe39a0771eecd20f430f3678533476f0087138f356", size = 3336978 }, - { url = "https://files.pythonhosted.org/packages/8a/07/b47472d2ffd0776826f17ccf0b4d01b224c99fbd1904aeb103dffbb4b1cc/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5a6c555da8d4280a3c4c78c5b7a3f990cee2b2884e5f934f87a226191682ff7", size = 3349939 }, - { url = "https://files.pythonhosted.org/packages/bb/c6/95fa32b79b57769da3e16f054cf658d90940317b5ca0ec20eac84aa19c4f/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ed48a1701d24dff3bb49a5bce94d6bc84cbe33d98af2aa2d3cdcce3dea1709ec", size = 3279648 }, - { url = "https://files.pythonhosted.org/packages/bb/c8/3d07e7c73928dc59a0bed40961ca4e313e797bce650b088e8d5fdd3ad939/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f3178c920ad98158f0b6309382194df04b14808fa6052ae07099fdde29d5602", size = 3314695 }, - { url = "https://files.pythonhosted.org/packages/6b/d2/ed32b1611c1e19fdb028eee1adc5a9aa138c2952d09ae11f1670170f80ae/sqlalchemy-2.0.47-cp312-cp312-win32.whl", hash = "sha256:b9c11ac9934dd59ece9619fe42780a08abe2faab7b0543bb00d5eabea4f421b9", size = 2115502 }, - { url = "https://files.pythonhosted.org/packages/fd/52/9de590356a4dd8e9ef5a881dbba64b2bbc4cbc71bf02bc68e775fb9b1899/sqlalchemy-2.0.47-cp312-cp312-win_amd64.whl", hash = "sha256:db43b72cf8274a99e089755c9c1e0b947159b71adbc2c83c3de2e38d5d607acb", size = 2142435 }, - { url = "https://files.pythonhosted.org/packages/4a/e5/0af64ce7d8f60ec5328c10084e2f449e7912a9b8bdbefdcfb44454a25f49/sqlalchemy-2.0.47-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:456a135b790da5d3c6b53d0ef71ac7b7d280b7f41eb0c438986352bf03ca7143", size = 2152551 }, - { url = "https://files.pythonhosted.org/packages/63/79/746b8d15f6940e2ac469ce22d7aa5b1124b1ab820bad9b046eb3000c88a6/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09a2f7698e44b3135433387da5d8846cf7cc7c10e5425af7c05fee609df978b6", size = 3278782 }, - { url = "https://files.pythonhosted.org/packages/91/b1/bd793ddb34345d1ed43b13ab2d88c95d7d4eb2e28f5b5a99128b9cc2bca2/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bbc72e6a177c78d724f9106aaddc0d26a2ada89c6332b5935414eccf04cbd5", size = 3295155 }, - { url = "https://files.pythonhosted.org/packages/97/84/7213def33f94e5ca6f5718d259bc9f29de0363134648425aa218d4356b23/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75460456b043b78b6006e41bdf5b86747ee42eafaf7fffa3b24a6e9a456a2092", size = 3226834 }, - { url = "https://files.pythonhosted.org/packages/ef/06/456810204f4dc29b5f025b1b0a03b4bd6b600ebf3c1040aebd90a257fa33/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d9adaa616c3bc7d80f9ded57cd84b51d6617cad6a5456621d858c9f23aaee01", size = 3265001 }, - { url = "https://files.pythonhosted.org/packages/fb/20/df3920a4b2217dbd7390a5bd277c1902e0393f42baaf49f49b3c935e7328/sqlalchemy-2.0.47-cp313-cp313-win32.whl", hash = "sha256:76e09f974382a496a5ed985db9343628b1cb1ac911f27342e4cc46a8bac10476", size = 2113647 }, - { url = "https://files.pythonhosted.org/packages/46/06/7873ddf69918efbfabd7211829f4bd8019739d0a719253112d305d3ba51d/sqlalchemy-2.0.47-cp313-cp313-win_amd64.whl", hash = "sha256:0664089b0bf6724a0bfb49a0cf4d4da24868a0a5c8e937cd7db356d5dcdf2c66", size = 2139425 }, - { url = "https://files.pythonhosted.org/packages/54/fa/61ad9731370c90ac7ea5bf8f5eaa12c48bb4beec41c0fa0360becf4ac10d/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed0c967c701ae13da98eb220f9ddab3044ab63504c1ba24ad6a59b26826ad003", size = 3558809 }, - { url = "https://files.pythonhosted.org/packages/33/d5/221fac96f0529391fe374875633804c866f2b21a9c6d3a6ca57d9c12cfd7/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3537943a61fd25b241e976426a0c6814434b93cf9b09d39e8e78f3c9eb9a487", size = 3525480 }, - { url = "https://files.pythonhosted.org/packages/ec/55/8247d53998c3673e4a8d1958eba75c6f5cc3b39082029d400bb1f2a911ae/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57f7e336a64a0dba686c66392d46b9bc7af2c57d55ce6dc1697b4ef32b043ceb", size = 3466569 }, - { url = "https://files.pythonhosted.org/packages/6b/b5/c1f0eea1bac6790845f71420a7fe2f2a0566203aa57543117d4af3b77d1c/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dff735a621858680217cb5142b779bad40ef7322ddbb7c12062190db6879772e", size = 3475770 }, - { url = "https://files.pythonhosted.org/packages/c5/ed/2f43f92474ea0c43c204657dc47d9d002cd738b96ca2af8e6d29a9b5e42d/sqlalchemy-2.0.47-cp313-cp313t-win32.whl", hash = "sha256:3893dc096bb3cca9608ea3487372ffcea3ae9b162f40e4d3c51dd49db1d1b2dc", size = 2141300 }, - { url = "https://files.pythonhosted.org/packages/cc/a9/8b73f9f1695b6e92f7aaf1711135a1e3bbeb78bca9eded35cb79180d3c6d/sqlalchemy-2.0.47-cp313-cp313t-win_amd64.whl", hash = "sha256:b5103427466f4b3e61f04833ae01f9a914b1280a2a8bcde3a9d7ab11f3755b42", size = 2173053 }, - { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355 }, - { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486 }, - { url = "https://files.pythonhosted.org/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6", size = 3279481 }, - { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269 }, - { url = "https://files.pythonhosted.org/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f", size = 3246262 }, - { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528 }, - { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181 }, - { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477 }, - { url = "https://files.pythonhosted.org/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172", size = 3528568 }, - { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284 }, - { url = "https://files.pythonhosted.org/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706", size = 3478410 }, - { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164 }, - { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154 }, - { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159 }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323, upload-time = "2026-02-24T16:34:27.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/88/74eb470223ff88ea6572a132c0b8de8c1d8ed7b843d3b44a8a3c77f31d39/sqlalchemy-2.0.47-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fa91b19d6b9821c04cc8f7aa2476429cc8887b9687c762815aa629f5c0edec1", size = 2155687, upload-time = "2026-02-24T17:05:46.451Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ba/1447d3d558971b036cb93b557595cb5dcdfe728f1c7ac4dec16505ef5756/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c5bbbd14eff577c8c79cbfe39a0771eecd20f430f3678533476f0087138f356", size = 3336978, upload-time = "2026-02-24T17:18:04.597Z" }, + { url = "https://files.pythonhosted.org/packages/8a/07/b47472d2ffd0776826f17ccf0b4d01b224c99fbd1904aeb103dffbb4b1cc/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5a6c555da8d4280a3c4c78c5b7a3f990cee2b2884e5f934f87a226191682ff7", size = 3349939, upload-time = "2026-02-24T17:27:18.937Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c6/95fa32b79b57769da3e16f054cf658d90940317b5ca0ec20eac84aa19c4f/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ed48a1701d24dff3bb49a5bce94d6bc84cbe33d98af2aa2d3cdcce3dea1709ec", size = 3279648, upload-time = "2026-02-24T17:18:07.038Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c8/3d07e7c73928dc59a0bed40961ca4e313e797bce650b088e8d5fdd3ad939/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f3178c920ad98158f0b6309382194df04b14808fa6052ae07099fdde29d5602", size = 3314695, upload-time = "2026-02-24T17:27:20.93Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed32b1611c1e19fdb028eee1adc5a9aa138c2952d09ae11f1670170f80ae/sqlalchemy-2.0.47-cp312-cp312-win32.whl", hash = "sha256:b9c11ac9934dd59ece9619fe42780a08abe2faab7b0543bb00d5eabea4f421b9", size = 2115502, upload-time = "2026-02-24T17:22:52.546Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/9de590356a4dd8e9ef5a881dbba64b2bbc4cbc71bf02bc68e775fb9b1899/sqlalchemy-2.0.47-cp312-cp312-win_amd64.whl", hash = "sha256:db43b72cf8274a99e089755c9c1e0b947159b71adbc2c83c3de2e38d5d607acb", size = 2142435, upload-time = "2026-02-24T17:22:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/0af64ce7d8f60ec5328c10084e2f449e7912a9b8bdbefdcfb44454a25f49/sqlalchemy-2.0.47-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:456a135b790da5d3c6b53d0ef71ac7b7d280b7f41eb0c438986352bf03ca7143", size = 2152551, upload-time = "2026-02-24T17:05:47.675Z" }, + { url = "https://files.pythonhosted.org/packages/63/79/746b8d15f6940e2ac469ce22d7aa5b1124b1ab820bad9b046eb3000c88a6/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09a2f7698e44b3135433387da5d8846cf7cc7c10e5425af7c05fee609df978b6", size = 3278782, upload-time = "2026-02-24T17:18:10.012Z" }, + { url = "https://files.pythonhosted.org/packages/91/b1/bd793ddb34345d1ed43b13ab2d88c95d7d4eb2e28f5b5a99128b9cc2bca2/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bbc72e6a177c78d724f9106aaddc0d26a2ada89c6332b5935414eccf04cbd5", size = 3295155, upload-time = "2026-02-24T17:27:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/97/84/7213def33f94e5ca6f5718d259bc9f29de0363134648425aa218d4356b23/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75460456b043b78b6006e41bdf5b86747ee42eafaf7fffa3b24a6e9a456a2092", size = 3226834, upload-time = "2026-02-24T17:18:11.465Z" }, + { url = "https://files.pythonhosted.org/packages/ef/06/456810204f4dc29b5f025b1b0a03b4bd6b600ebf3c1040aebd90a257fa33/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d9adaa616c3bc7d80f9ded57cd84b51d6617cad6a5456621d858c9f23aaee01", size = 3265001, upload-time = "2026-02-24T17:27:24.813Z" }, + { url = "https://files.pythonhosted.org/packages/fb/20/df3920a4b2217dbd7390a5bd277c1902e0393f42baaf49f49b3c935e7328/sqlalchemy-2.0.47-cp313-cp313-win32.whl", hash = "sha256:76e09f974382a496a5ed985db9343628b1cb1ac911f27342e4cc46a8bac10476", size = 2113647, upload-time = "2026-02-24T17:22:55.747Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/7873ddf69918efbfabd7211829f4bd8019739d0a719253112d305d3ba51d/sqlalchemy-2.0.47-cp313-cp313-win_amd64.whl", hash = "sha256:0664089b0bf6724a0bfb49a0cf4d4da24868a0a5c8e937cd7db356d5dcdf2c66", size = 2139425, upload-time = "2026-02-24T17:22:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/54/fa/61ad9731370c90ac7ea5bf8f5eaa12c48bb4beec41c0fa0360becf4ac10d/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed0c967c701ae13da98eb220f9ddab3044ab63504c1ba24ad6a59b26826ad003", size = 3558809, upload-time = "2026-02-24T17:12:15.232Z" }, + { url = "https://files.pythonhosted.org/packages/33/d5/221fac96f0529391fe374875633804c866f2b21a9c6d3a6ca57d9c12cfd7/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3537943a61fd25b241e976426a0c6814434b93cf9b09d39e8e78f3c9eb9a487", size = 3525480, upload-time = "2026-02-24T17:27:59.602Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/8247d53998c3673e4a8d1958eba75c6f5cc3b39082029d400bb1f2a911ae/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57f7e336a64a0dba686c66392d46b9bc7af2c57d55ce6dc1697b4ef32b043ceb", size = 3466569, upload-time = "2026-02-24T17:12:16.94Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b5/c1f0eea1bac6790845f71420a7fe2f2a0566203aa57543117d4af3b77d1c/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dff735a621858680217cb5142b779bad40ef7322ddbb7c12062190db6879772e", size = 3475770, upload-time = "2026-02-24T17:28:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ed/2f43f92474ea0c43c204657dc47d9d002cd738b96ca2af8e6d29a9b5e42d/sqlalchemy-2.0.47-cp313-cp313t-win32.whl", hash = "sha256:3893dc096bb3cca9608ea3487372ffcea3ae9b162f40e4d3c51dd49db1d1b2dc", size = 2141300, upload-time = "2026-02-24T17:14:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/8b73f9f1695b6e92f7aaf1711135a1e3bbeb78bca9eded35cb79180d3c6d/sqlalchemy-2.0.47-cp313-cp313t-win_amd64.whl", hash = "sha256:b5103427466f4b3e61f04833ae01f9a914b1280a2a8bcde3a9d7ab11f3755b42", size = 2173053, upload-time = "2026-02-24T17:14:38.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, + { url = "https://files.pythonhosted.org/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6", size = 3279481, upload-time = "2026-02-24T17:27:26.491Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, + { url = "https://files.pythonhosted.org/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f", size = 3246262, upload-time = "2026-02-24T17:27:27.986Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528, upload-time = "2026-02-24T17:22:59.363Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181, upload-time = "2026-02-24T17:23:01.001Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172", size = 3528568, upload-time = "2026-02-24T17:28:03.732Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706", size = 3478410, upload-time = "2026-02-24T17:28:05.892Z" }, + { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154, upload-time = "2026-02-24T17:14:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" }, ] [package.optional-dependencies] @@ -4472,27 +4472,27 @@ dependencies = [ { name = "ply" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/6f/ef25bbc1aefeb9c905d527f1d3cd3f41f22f40566d33001b8bb14ae0cdaf/stone-3.3.1.tar.gz", hash = "sha256:4ef0397512f609757975f7ec09b35639d72ba7e3e17ce4ddf399578346b4cb50", size = 190888 } +sdist = { url = "https://files.pythonhosted.org/packages/99/6f/ef25bbc1aefeb9c905d527f1d3cd3f41f22f40566d33001b8bb14ae0cdaf/stone-3.3.1.tar.gz", hash = "sha256:4ef0397512f609757975f7ec09b35639d72ba7e3e17ce4ddf399578346b4cb50", size = 190888, upload-time = "2022-01-25T21:32:16.729Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/92/d0c83f63d3518e5f0b8a311937c31347349ec9a47b209ddc17f7566f58fc/stone-3.3.1-py3-none-any.whl", hash = "sha256:e15866fad249c11a963cce3bdbed37758f2e88c8ff4898616bc0caeb1e216047", size = 162257 }, + { url = "https://files.pythonhosted.org/packages/5c/92/d0c83f63d3518e5f0b8a311937c31347349ec9a47b209ddc17f7566f58fc/stone-3.3.1-py3-none-any.whl", hash = "sha256:e15866fad249c11a963cce3bdbed37758f2e88c8ff4898616bc0caeb1e216047", size = 162257, upload-time = "2022-01-25T21:32:15.155Z" }, ] [[package]] name = "striprtf" version = "0.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/20/3d419008265346452d09e5dadfd5d045b64b40d8fc31af40588e6c76997a/striprtf-0.0.26.tar.gz", hash = "sha256:fdb2bba7ac440072d1c41eab50d8d74ae88f60a8b6575c6e2c7805dc462093aa", size = 6258 } +sdist = { url = "https://files.pythonhosted.org/packages/25/20/3d419008265346452d09e5dadfd5d045b64b40d8fc31af40588e6c76997a/striprtf-0.0.26.tar.gz", hash = "sha256:fdb2bba7ac440072d1c41eab50d8d74ae88f60a8b6575c6e2c7805dc462093aa", size = 6258, upload-time = "2023-07-20T14:30:36.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/cf/0fea4f4ba3fc2772ac2419278aa9f6964124d4302117d61bc055758e000c/striprtf-0.0.26-py3-none-any.whl", hash = "sha256:8c8f9d32083cdc2e8bfb149455aa1cc5a4e0a035893bedc75db8b73becb3a1bb", size = 6914 }, + { url = "https://files.pythonhosted.org/packages/a3/cf/0fea4f4ba3fc2772ac2419278aa9f6964124d4302117d61bc055758e000c/striprtf-0.0.26-py3-none-any.whl", hash = "sha256:8c8f9d32083cdc2e8bfb149455aa1cc5a4e0a035893bedc75db8b73becb3a1bb", size = 6914, upload-time = "2023-07-20T14:30:35.338Z" }, ] [[package]] name = "tenacity" version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] @@ -4503,52 +4503,52 @@ dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728 }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049 }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008 }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665 }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230 }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688 }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694 }, - { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802 }, - { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995 }, - { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948 }, - { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986 }, - { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222 }, - { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097 }, - { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117 }, - { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309 }, - { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712 }, - { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725 }, - { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875 }, - { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451 }, - { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794 }, - { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777 }, - { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188 }, - { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978 }, - { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271 }, - { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216 }, - { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860 }, - { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567 }, - { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067 }, - { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473 }, - { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855 }, - { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022 }, - { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736 }, - { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908 }, - { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706 }, - { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667 }, +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] [[package]] name = "tinytag" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/59/8a8cb2331e2602b53e4dc06960f57d1387a2b18e7efd24e5f9cb60ea4925/tinytag-2.2.1.tar.gz", hash = "sha256:e6d06610ebe7cd66fd07be2d3b9495914ab32654a5e47657bb8cd44c2484523c", size = 38214 } +sdist = { url = "https://files.pythonhosted.org/packages/96/59/8a8cb2331e2602b53e4dc06960f57d1387a2b18e7efd24e5f9cb60ea4925/tinytag-2.2.1.tar.gz", hash = "sha256:e6d06610ebe7cd66fd07be2d3b9495914ab32654a5e47657bb8cd44c2484523c", size = 38214, upload-time = "2026-03-15T18:48:01.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/34/d50e338631baaf65ec5396e70085e5de0b52b24b28db1ffbc1c6e82190dc/tinytag-2.2.1-py3-none-any.whl", hash = "sha256:ed8b1e6d25367937e3321e054f4974f9abfde1a3e0a538824c87da377130c2b6", size = 32927 }, + { url = "https://files.pythonhosted.org/packages/ce/34/d50e338631baaf65ec5396e70085e5de0b52b24b28db1ffbc1c6e82190dc/tinytag-2.2.1-py3-none-any.whl", hash = "sha256:ed8b1e6d25367937e3321e054f4974f9abfde1a3e0a538824c87da377130c2b6", size = 32927, upload-time = "2026-03-15T18:47:59.613Z" }, ] [[package]] @@ -4558,32 +4558,32 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115 } +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275 }, - { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472 }, - { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736 }, - { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835 }, - { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673 }, - { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818 }, - { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195 }, - { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982 }, - { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245 }, - { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069 }, - { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263 }, - { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429 }, - { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363 }, - { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786 }, - { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133 }, + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] [[package]] name = "tomlkit" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310 }, + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, ] [[package]] @@ -4593,9 +4593,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598 } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374 }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] @@ -4608,18 +4608,18 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813 }, + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +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/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { 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]] @@ -4630,9 +4630,9 @@ dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, ] [[package]] @@ -4642,74 +4642,79 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2025.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 }, + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] [[package]] name = "ujson" version = "5.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/78/937198ea8708182dd1edbf0237bf255a96feab3f511691ad08b84da98e5d/ujson-5.12.1.tar.gz", hash = "sha256:5b7e96406c301a1366534479a7352ec40ec68bb327c0c119091635acd5925e35", size = 7164538 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/40/dbb8e2fe6ee33769602fba203dacaa3963b6599f0d0aefdf2b8811af5f70/ujson-5.12.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:10f44bd08ae52ee23ca6e8b472692e5da1768af2d53ff1bad6f40b532e0bc7ee", size = 57951 }, - { url = "https://files.pythonhosted.org/packages/8d/db/627472e6b4ac34148ea52e6d3d15f6f366fc21c72fe7d6c7d3729d4b3ac5/ujson-5.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cc6ea753b7303fa5629fa9ac9257ea4b001c4d72583b2bb36ff1855a07db49f", size = 55562 }, - { url = "https://files.pythonhosted.org/packages/be/59/1248c966da197ae7d2673542444a2d9a1ff7c46e3ec2a302c3caf902b922/ujson-5.12.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:289f13095764d03734adfa10107da9b530ceb64dc1b02a5f507588d978d5b7df", size = 59448 }, - { url = "https://files.pythonhosted.org/packages/d5/d7/60c1ca71a09c0654c3edca1192a18fc55e6cc06107be86d7d3f2b39fb29b/ujson-5.12.1-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:427893168d074e59214b0ee058337c57f5bb80175cdd5b4799a9c931aae22022", size = 61608 }, - { url = "https://files.pythonhosted.org/packages/d5/0a/c619525576219bfc50084100117481b1a732a16716a3878355570995de4e/ujson-5.12.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7a81724d5d90a2da7155d15d8b156ce57eaed7cdd622df813f36a8e612fd4c8", size = 59113 }, - { url = "https://files.pythonhosted.org/packages/18/4d/79c1674036085e8dfdb77f8d87c1fd2896e97e6affd117c5e8ecc40f0ae4/ujson-5.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a6efff7dc6515416366819de4a1bc449b77107c5b48508b101fd40f7f8bec08", size = 1038914 }, - { url = "https://files.pythonhosted.org/packages/94/b1/9409bba17189ee282b6314cdf0ecdcc72e3d38cd565c870c0227d0494569/ujson-5.12.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77a71fe53427a0cf49d56eafd801d9f7e203b784b7f99cc717783fd6f6f7b732", size = 1198408 }, - { url = "https://files.pythonhosted.org/packages/4b/ad/fafbce7ac59f1a10a83892d0a34add23cc06492308e1330493aab707dc20/ujson-5.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea3bed53d2ea8e5642e814a9e41f3e29420a8067874ba03ace8c0462e160490c", size = 1091451 }, - { url = "https://files.pythonhosted.org/packages/5a/1f/76fc9d5b1dcb9eb73ed45fd56e5114391bd30808eb1cea7f8bc5c9a64324/ujson-5.12.1-cp312-cp312-win32.whl", hash = "sha256:758e5c8fbe4e6d483041e03b307b01fb5d2f2dd4452d4d4b927ab902e188939e", size = 41049 }, - { url = "https://files.pythonhosted.org/packages/35/2a/7ce3b6fda10d05b79a245db03405734b521ba3da6c377f173b018dce6d4e/ujson-5.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:f6074d3d3267ba1914c624b6e1fa3d8152648ff36b0ab77ddf83b92db488c30d", size = 45330 }, - { url = "https://files.pythonhosted.org/packages/d7/66/5a37bba7a2e2ab36ae467521c4511e6593ad74c869f62ec4ba6330f3f71e/ujson-5.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:7642a41520ac1b2bc25ea282b66b8da522cc43424442e6fb5e039be4d4f96530", size = 39828 }, - { url = "https://files.pythonhosted.org/packages/b9/f0/985b351771ebf095e2c1aaad18f4d251831226a767a32593310e4f181f19/ujson-5.12.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c4bdc052a5d097f0a2e56d93aed97355f9f7a62ef9baa4f8517e43245434af9c", size = 57959 }, - { url = "https://files.pythonhosted.org/packages/61/73/03c7473372e1a538206fc655e474fa15f8bf9c46bb7c73c5fec9a544e429/ujson-5.12.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5dc91fa06ea35920b704fd9d70871897680145998071cfbf5ee3e19f2c9fc242", size = 55564 }, - { url = "https://files.pythonhosted.org/packages/04/e6/104ebc35fa8dbaca66bf027c53c0c9c572271c2984576f4fd7d349d1a2e4/ujson-5.12.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5db0849c0e3da54822a5834f2dc51d7c51072d7f7d665014ee34600dc10889b", size = 59448 }, - { url = "https://files.pythonhosted.org/packages/11/d2/55274e80fe1806cdb5cb97483be16cd6163337ab11c3bd7e28ff8a8aad26/ujson-5.12.1-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:949cb4863a5d4847edeb47c5364b334e8cadf23a7cbdaa547d86098a4b093106", size = 61611 }, - { url = "https://files.pythonhosted.org/packages/6c/15/ec46b1757c8f7770d8c101b8a463bec67c19e89c46c608d01e4b193cc64a/ujson-5.12.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8aa731138d6dfca4ab84501b72384e6c544bfb48cb87a0dd4d304df3246cac25", size = 59120 }, - { url = "https://files.pythonhosted.org/packages/b5/27/ec73bc8908c33eb1f5be29d696084e531cbcfbd5c7b89ce54c025f66c682/ujson-5.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:727e983ef27892d86ee2d28fd517eeb02b2c1165aafcbe929dce988aeee81bfe", size = 1038913 }, - { url = "https://files.pythonhosted.org/packages/6d/30/907e47569bed5f5eb258fef5e587c6759a7a062048796e40024497137e28/ujson-5.12.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d57d731ecf492d3d011e65369f8330654f0875b19f646be5270d478e843d3b81", size = 1198409 }, - { url = "https://files.pythonhosted.org/packages/46/aa/f135f4b741baf14d5350be5511076408e7540353d3d850a430cb89d585a6/ujson-5.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a09636220f26c66f80c6c6283023cb53120e843825f890be92696cd1aa43f39", size = 1091456 }, - { url = "https://files.pythonhosted.org/packages/6e/81/5e6ef1115c0f700a74a150857c66cb22245f0e43f79667af9bf2b88f9452/ujson-5.12.1-cp313-cp313-win32.whl", hash = "sha256:ee83fbac03a0896faf190177c938f94eb610b798d495a19d50997242c4eca685", size = 41055 }, - { url = "https://files.pythonhosted.org/packages/98/76/8b423bc72a02f3fcf90f911a16382f360442c1a8887955c023d517f5d4ba/ujson-5.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:e08d9e096c416ddc34519241f97c201258b42639f2012d9547d8ae32921800dd", size = 45331 }, - { url = "https://files.pythonhosted.org/packages/5f/f2/c839a923da49384d4a319ddd5ce666e50e45a5c8417cec742c65667a1864/ujson-5.12.1-cp313-cp313-win_arm64.whl", hash = "sha256:963287e4b1bc463735c4056968a2dfa59bb831b6daba68bddd14f451191fe9e5", size = 39828 }, - { url = "https://files.pythonhosted.org/packages/f8/ca/d88d86f90f8f237985f3e347b9a4f9fa24e8d30d19ec7d477ed18aa58393/ujson-5.12.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f19e9a407a24230df0cc1ec1c0f5999872ba526b14a780f80ad6479f5eed9bc", size = 58099 }, - { url = "https://files.pythonhosted.org/packages/ae/2d/a0a88407cee3550f7ed1e49b41157ee2d410f51905ed51fb134844255280/ujson-5.12.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8b657e870c77aaacdeea86cfad3e6d2ef9b52517e45988c9c367f7ee764fe4dd", size = 55631 }, - { url = "https://files.pythonhosted.org/packages/a9/6d/12a3b8e72132db244ae048075e71a0079b3c5f61ff45b7ca81d5193ab3e7/ujson-5.12.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984b5a99d1e0a037c2046c3c4b34cec832565d62d5017be0a035bf3cbfab72dc", size = 59469 }, - { url = "https://files.pythonhosted.org/packages/a2/72/310f8c21737554f2d2b4f1883e1a71e8a6ab0d8f92f0feb8aaa85e0f4b66/ujson-5.12.1-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:f48ef8a16f1d85bd7982beac7adfd3fb704058631db84c1c61c8a1b7072b1508", size = 61611 }, - { url = "https://files.pythonhosted.org/packages/50/50/ab4b2f7bab6c7a67298c8f2aca80e2082eaf6f332cf2d099762647b5301e/ujson-5.12.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f39ba3b65cc637b59731532f7e7c807786bff1d0332ab2d5b96a04d2584d78f", size = 59122 }, - { url = "https://files.pythonhosted.org/packages/21/48/5d81cbe76fc2aa9e071aa489a3041cf0712f5e0663d60d501641f92b7bb4/ujson-5.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:07f307780f85b49cba93f291718421b6f5f3b627a323b431fad937a18f6587cb", size = 1038938 }, - { url = "https://files.pythonhosted.org/packages/fb/a7/abe1acb0e5d8b8d724b35533a44c89684c88100a5fd9f2fee7f7155528d5/ujson-5.12.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1c335caea51c31494e514b82d50763b9792d3960d2c7d9fdb6b6fb8ed50ebdd0", size = 1198416 }, - { url = "https://files.pythonhosted.org/packages/ed/6e/087067d6ee22bd01bfba9fb1f32ce98c24ae2bcbab53bd2fbf8f7a80fe9e/ujson-5.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:19ea07e29a45d199f926aadf93a9974128438c01b83141fba32477c0ee604b33", size = 1091425 }, - { url = "https://files.pythonhosted.org/packages/4e/d2/28938574b766980f873b68962abb4c68a944d939446768982934ad3bcd93/ujson-5.12.1-cp314-cp314-win32.whl", hash = "sha256:c8e626b6bc9bdd2e8f7393b7d99f3daa2ca4022e6203662e70de7bb3604b21b9", size = 42334 }, - { url = "https://files.pythonhosted.org/packages/49/b0/0af30bf65d96b73c28054b344ebbe24bc96780ae8a7f2973f5dad979510a/ujson-5.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:c6d3bdd020333688ee60559437021ed68a98a28fdd609b5af16de5dd58f90cba", size = 46586 }, - { url = "https://files.pythonhosted.org/packages/4e/3b/0ee2555823724e60cc847c715c299f5792aa444bdde69c51d4aa42d885c2/ujson-5.12.1-cp314-cp314-win_arm64.whl", hash = "sha256:e3c9c894971f4ada3ded16a804ed4640e1f2b3e5239beaeec7c48296f39f4232", size = 41178 }, - { url = "https://files.pythonhosted.org/packages/3f/3d/7547835cd0b7fa22eb1122702f81b2403c38a0027a2cc0d75acc449a4a66/ujson-5.12.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:49dd9c378e1c8e676785ff2b62cb490074229f15ab54abf45b623713cb2c36b5", size = 58565 }, - { url = "https://files.pythonhosted.org/packages/ed/6a/1784e0b24aab50623eb47b2f7a8dc22c9d809d798854d2568a9cb7c3560f/ujson-5.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d8827904358d7da59ccf2e1fd8de59e78248036d17fecc0462e62c6721f1102", size = 56157 }, - { url = "https://files.pythonhosted.org/packages/91/2d/2c1b24df24eee309047d81460c3a1acf0d047207327edc6f3cab8a614985/ujson-5.12.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc26caebea90425662ef0b979f945f6ac832651881107d6ec9a3c4d4a4ba929c", size = 60288 }, - { url = "https://files.pythonhosted.org/packages/c5/14/c0c603e3dff2ef98f7deee2df7795e6055abbc5825c6ef530024b3b06a15/ujson-5.12.1-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:45022aae09ac3d45bda6fbfc631088d1aff9a0465542d40bd6d295ced378c430", size = 62302 }, - { url = "https://files.pythonhosted.org/packages/5c/0d/889bbc044561d9adc9bf413620fbd9878f352c9fd36da829d319bca2f5ad/ujson-5.12.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b22aa0f644516d3d5b29464949e4b23fe784f84b4a1030ab9ac3cb42aaedabb1", size = 59784 }, - { url = "https://files.pythonhosted.org/packages/18/35/3b1d8ff8cd6dc048f5c495af6ee6ded43055562610a7e9b78b438dc6421e/ujson-5.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7dc5cf44ea42365cd1b66e6ed3fc6ca040c86587b024a6659b98e99d31cff2cd", size = 1039759 }, - { url = "https://files.pythonhosted.org/packages/6a/d8/3c66cdf839420a6da2d6140a54a882c15efd135bcced103bd4473d577636/ujson-5.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8df5d984ff4ac1ef292d70f30da03417038a7e1e0bc272d28ca9d34f02f41682", size = 1199121 }, - { url = "https://files.pythonhosted.org/packages/54/51/c3d1b94a4ad27dc7532e9f7d00b869463157cede2295ba6d57566afeb8cd/ujson-5.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:485f0182a0c0b54c304061cdc826d8343ce595c4055f7a24e72772a8520e5f7b", size = 1092085 }, - { url = "https://files.pythonhosted.org/packages/ae/52/4d4a6e78290a5eef3f576f6d281e6355535db903a08483fd1bb393bf8cb9/ujson-5.12.1-cp314-cp314t-win32.whl", hash = "sha256:4e12ca368b397aed7fa1eec534ea1ba8d94977b376f9df3e93ae1acfd004ec40", size = 43243 }, - { url = "https://files.pythonhosted.org/packages/3d/c8/849366785de52b513e5fc89d7aea0b531e71bb5641407cbdfdf47a99ede8/ujson-5.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:cec6b9b539539affc1f01a795c99574592a635ce22331b64f2b42e0af570659e", size = 47662 }, - { url = "https://files.pythonhosted.org/packages/8a/46/36a67f5a531a15308124786f3e2b7b96414b9d23dbcdc2a182dd3ffa2e1d/ujson-5.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:696224d4cfb8883fa5c0285dff31e5ce924704dd9ccd38e9ea8b5bf4a42b12fc", size = 41680 }, - { url = "https://files.pythonhosted.org/packages/6d/26/c9d0479236b3f5690d6a8bb45f708aabc2c91ca80d275eba24b1e9e464ab/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c419bf42ae40963fc27f70c59e24e9a97f5cf168dbce2c572f3c0ce3595912", size = 56153 }, - { url = "https://files.pythonhosted.org/packages/ee/c8/785f4e132500aff2f1fd2bd4a4b86fe396a5519f830a098358c90ebb92ee/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0be2b4f2f547b9f0f3d902640e410e5a2fc851576cbe033c88445a23e3e7aef1", size = 57352 }, - { url = "https://files.pythonhosted.org/packages/8f/13/b688a905653871b10b4ff0403c2ff562c17a0bd50be0d44324f3c85ca48f/ujson-5.12.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:4ea0c490c702c20495e97345acfcf0c2f3153e658ef537ff111929c48b89e10a", size = 45988 }, -] +sdist = { url = "https://files.pythonhosted.org/packages/bc/78/937198ea8708182dd1edbf0237bf255a96feab3f511691ad08b84da98e5d/ujson-5.12.1.tar.gz", hash = "sha256:5b7e96406c301a1366534479a7352ec40ec68bb327c0c119091635acd5925e35", size = 7164538, upload-time = "2026-05-05T22:05:01.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/40/dbb8e2fe6ee33769602fba203dacaa3963b6599f0d0aefdf2b8811af5f70/ujson-5.12.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:10f44bd08ae52ee23ca6e8b472692e5da1768af2d53ff1bad6f40b532e0bc7ee", size = 57951, upload-time = "2026-05-05T22:03:31.606Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/627472e6b4ac34148ea52e6d3d15f6f366fc21c72fe7d6c7d3729d4b3ac5/ujson-5.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cc6ea753b7303fa5629fa9ac9257ea4b001c4d72583b2bb36ff1855a07db49f", size = 55562, upload-time = "2026-05-05T22:03:32.853Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/1248c966da197ae7d2673542444a2d9a1ff7c46e3ec2a302c3caf902b922/ujson-5.12.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:289f13095764d03734adfa10107da9b530ceb64dc1b02a5f507588d978d5b7df", size = 59448, upload-time = "2026-05-05T22:03:34.143Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d7/60c1ca71a09c0654c3edca1192a18fc55e6cc06107be86d7d3f2b39fb29b/ujson-5.12.1-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:427893168d074e59214b0ee058337c57f5bb80175cdd5b4799a9c931aae22022", size = 61608, upload-time = "2026-05-05T22:03:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0a/c619525576219bfc50084100117481b1a732a16716a3878355570995de4e/ujson-5.12.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7a81724d5d90a2da7155d15d8b156ce57eaed7cdd622df813f36a8e612fd4c8", size = 59113, upload-time = "2026-05-05T22:03:37.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/4d/79c1674036085e8dfdb77f8d87c1fd2896e97e6affd117c5e8ecc40f0ae4/ujson-5.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a6efff7dc6515416366819de4a1bc449b77107c5b48508b101fd40f7f8bec08", size = 1038914, upload-time = "2026-05-05T22:03:38.954Z" }, + { url = "https://files.pythonhosted.org/packages/94/b1/9409bba17189ee282b6314cdf0ecdcc72e3d38cd565c870c0227d0494569/ujson-5.12.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77a71fe53427a0cf49d56eafd801d9f7e203b784b7f99cc717783fd6f6f7b732", size = 1198408, upload-time = "2026-05-05T22:03:40.943Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ad/fafbce7ac59f1a10a83892d0a34add23cc06492308e1330493aab707dc20/ujson-5.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea3bed53d2ea8e5642e814a9e41f3e29420a8067874ba03ace8c0462e160490c", size = 1091451, upload-time = "2026-05-05T22:03:42.739Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1f/76fc9d5b1dcb9eb73ed45fd56e5114391bd30808eb1cea7f8bc5c9a64324/ujson-5.12.1-cp312-cp312-win32.whl", hash = "sha256:758e5c8fbe4e6d483041e03b307b01fb5d2f2dd4452d4d4b927ab902e188939e", size = 41049, upload-time = "2026-05-05T22:03:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/35/2a/7ce3b6fda10d05b79a245db03405734b521ba3da6c377f173b018dce6d4e/ujson-5.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:f6074d3d3267ba1914c624b6e1fa3d8152648ff36b0ab77ddf83b92db488c30d", size = 45330, upload-time = "2026-05-05T22:03:45.828Z" }, + { url = "https://files.pythonhosted.org/packages/d7/66/5a37bba7a2e2ab36ae467521c4511e6593ad74c869f62ec4ba6330f3f71e/ujson-5.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:7642a41520ac1b2bc25ea282b66b8da522cc43424442e6fb5e039be4d4f96530", size = 39828, upload-time = "2026-05-05T22:03:47.123Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f0/985b351771ebf095e2c1aaad18f4d251831226a767a32593310e4f181f19/ujson-5.12.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c4bdc052a5d097f0a2e56d93aed97355f9f7a62ef9baa4f8517e43245434af9c", size = 57959, upload-time = "2026-05-05T22:03:48.348Z" }, + { url = "https://files.pythonhosted.org/packages/61/73/03c7473372e1a538206fc655e474fa15f8bf9c46bb7c73c5fec9a544e429/ujson-5.12.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5dc91fa06ea35920b704fd9d70871897680145998071cfbf5ee3e19f2c9fc242", size = 55564, upload-time = "2026-05-05T22:03:49.869Z" }, + { url = "https://files.pythonhosted.org/packages/04/e6/104ebc35fa8dbaca66bf027c53c0c9c572271c2984576f4fd7d349d1a2e4/ujson-5.12.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5db0849c0e3da54822a5834f2dc51d7c51072d7f7d665014ee34600dc10889b", size = 59448, upload-time = "2026-05-05T22:03:51.224Z" }, + { url = "https://files.pythonhosted.org/packages/11/d2/55274e80fe1806cdb5cb97483be16cd6163337ab11c3bd7e28ff8a8aad26/ujson-5.12.1-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:949cb4863a5d4847edeb47c5364b334e8cadf23a7cbdaa547d86098a4b093106", size = 61611, upload-time = "2026-05-05T22:03:52.731Z" }, + { url = "https://files.pythonhosted.org/packages/6c/15/ec46b1757c8f7770d8c101b8a463bec67c19e89c46c608d01e4b193cc64a/ujson-5.12.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8aa731138d6dfca4ab84501b72384e6c544bfb48cb87a0dd4d304df3246cac25", size = 59120, upload-time = "2026-05-05T22:03:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/ec73bc8908c33eb1f5be29d696084e531cbcfbd5c7b89ce54c025f66c682/ujson-5.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:727e983ef27892d86ee2d28fd517eeb02b2c1165aafcbe929dce988aeee81bfe", size = 1038913, upload-time = "2026-05-05T22:03:55.792Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/907e47569bed5f5eb258fef5e587c6759a7a062048796e40024497137e28/ujson-5.12.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d57d731ecf492d3d011e65369f8330654f0875b19f646be5270d478e843d3b81", size = 1198409, upload-time = "2026-05-05T22:03:57.947Z" }, + { url = "https://files.pythonhosted.org/packages/46/aa/f135f4b741baf14d5350be5511076408e7540353d3d850a430cb89d585a6/ujson-5.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a09636220f26c66f80c6c6283023cb53120e843825f890be92696cd1aa43f39", size = 1091456, upload-time = "2026-05-05T22:04:00.355Z" }, + { url = "https://files.pythonhosted.org/packages/6e/81/5e6ef1115c0f700a74a150857c66cb22245f0e43f79667af9bf2b88f9452/ujson-5.12.1-cp313-cp313-win32.whl", hash = "sha256:ee83fbac03a0896faf190177c938f94eb610b798d495a19d50997242c4eca685", size = 41055, upload-time = "2026-05-05T22:04:02.372Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/8b423bc72a02f3fcf90f911a16382f360442c1a8887955c023d517f5d4ba/ujson-5.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:e08d9e096c416ddc34519241f97c201258b42639f2012d9547d8ae32921800dd", size = 45331, upload-time = "2026-05-05T22:04:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f2/c839a923da49384d4a319ddd5ce666e50e45a5c8417cec742c65667a1864/ujson-5.12.1-cp313-cp313-win_arm64.whl", hash = "sha256:963287e4b1bc463735c4056968a2dfa59bb831b6daba68bddd14f451191fe9e5", size = 39828, upload-time = "2026-05-05T22:04:05.52Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ca/d88d86f90f8f237985f3e347b9a4f9fa24e8d30d19ec7d477ed18aa58393/ujson-5.12.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f19e9a407a24230df0cc1ec1c0f5999872ba526b14a780f80ad6479f5eed9bc", size = 58099, upload-time = "2026-05-05T22:04:06.688Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/a0a88407cee3550f7ed1e49b41157ee2d410f51905ed51fb134844255280/ujson-5.12.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8b657e870c77aaacdeea86cfad3e6d2ef9b52517e45988c9c367f7ee764fe4dd", size = 55631, upload-time = "2026-05-05T22:04:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6d/12a3b8e72132db244ae048075e71a0079b3c5f61ff45b7ca81d5193ab3e7/ujson-5.12.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984b5a99d1e0a037c2046c3c4b34cec832565d62d5017be0a035bf3cbfab72dc", size = 59469, upload-time = "2026-05-05T22:04:09.208Z" }, + { url = "https://files.pythonhosted.org/packages/a2/72/310f8c21737554f2d2b4f1883e1a71e8a6ab0d8f92f0feb8aaa85e0f4b66/ujson-5.12.1-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:f48ef8a16f1d85bd7982beac7adfd3fb704058631db84c1c61c8a1b7072b1508", size = 61611, upload-time = "2026-05-05T22:04:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/50/50/ab4b2f7bab6c7a67298c8f2aca80e2082eaf6f332cf2d099762647b5301e/ujson-5.12.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f39ba3b65cc637b59731532f7e7c807786bff1d0332ab2d5b96a04d2584d78f", size = 59122, upload-time = "2026-05-05T22:04:12.137Z" }, + { url = "https://files.pythonhosted.org/packages/21/48/5d81cbe76fc2aa9e071aa489a3041cf0712f5e0663d60d501641f92b7bb4/ujson-5.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:07f307780f85b49cba93f291718421b6f5f3b627a323b431fad937a18f6587cb", size = 1038938, upload-time = "2026-05-05T22:04:13.548Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a7/abe1acb0e5d8b8d724b35533a44c89684c88100a5fd9f2fee7f7155528d5/ujson-5.12.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1c335caea51c31494e514b82d50763b9792d3960d2c7d9fdb6b6fb8ed50ebdd0", size = 1198416, upload-time = "2026-05-05T22:04:15.609Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6e/087067d6ee22bd01bfba9fb1f32ce98c24ae2bcbab53bd2fbf8f7a80fe9e/ujson-5.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:19ea07e29a45d199f926aadf93a9974128438c01b83141fba32477c0ee604b33", size = 1091425, upload-time = "2026-05-05T22:04:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d2/28938574b766980f873b68962abb4c68a944d939446768982934ad3bcd93/ujson-5.12.1-cp314-cp314-win32.whl", hash = "sha256:c8e626b6bc9bdd2e8f7393b7d99f3daa2ca4022e6203662e70de7bb3604b21b9", size = 42334, upload-time = "2026-05-05T22:04:19.85Z" }, + { url = "https://files.pythonhosted.org/packages/49/b0/0af30bf65d96b73c28054b344ebbe24bc96780ae8a7f2973f5dad979510a/ujson-5.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:c6d3bdd020333688ee60559437021ed68a98a28fdd609b5af16de5dd58f90cba", size = 46586, upload-time = "2026-05-05T22:04:21.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3b/0ee2555823724e60cc847c715c299f5792aa444bdde69c51d4aa42d885c2/ujson-5.12.1-cp314-cp314-win_arm64.whl", hash = "sha256:e3c9c894971f4ada3ded16a804ed4640e1f2b3e5239beaeec7c48296f39f4232", size = 41178, upload-time = "2026-05-05T22:04:22.597Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3d/7547835cd0b7fa22eb1122702f81b2403c38a0027a2cc0d75acc449a4a66/ujson-5.12.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:49dd9c378e1c8e676785ff2b62cb490074229f15ab54abf45b623713cb2c36b5", size = 58565, upload-time = "2026-05-05T22:04:23.75Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/1784e0b24aab50623eb47b2f7a8dc22c9d809d798854d2568a9cb7c3560f/ujson-5.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d8827904358d7da59ccf2e1fd8de59e78248036d17fecc0462e62c6721f1102", size = 56157, upload-time = "2026-05-05T22:04:25.028Z" }, + { url = "https://files.pythonhosted.org/packages/91/2d/2c1b24df24eee309047d81460c3a1acf0d047207327edc6f3cab8a614985/ujson-5.12.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc26caebea90425662ef0b979f945f6ac832651881107d6ec9a3c4d4a4ba929c", size = 60288, upload-time = "2026-05-05T22:04:26.273Z" }, + { url = "https://files.pythonhosted.org/packages/c5/14/c0c603e3dff2ef98f7deee2df7795e6055abbc5825c6ef530024b3b06a15/ujson-5.12.1-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:45022aae09ac3d45bda6fbfc631088d1aff9a0465542d40bd6d295ced378c430", size = 62302, upload-time = "2026-05-05T22:04:27.516Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0d/889bbc044561d9adc9bf413620fbd9878f352c9fd36da829d319bca2f5ad/ujson-5.12.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b22aa0f644516d3d5b29464949e4b23fe784f84b4a1030ab9ac3cb42aaedabb1", size = 59784, upload-time = "2026-05-05T22:04:28.776Z" }, + { url = "https://files.pythonhosted.org/packages/18/35/3b1d8ff8cd6dc048f5c495af6ee6ded43055562610a7e9b78b438dc6421e/ujson-5.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7dc5cf44ea42365cd1b66e6ed3fc6ca040c86587b024a6659b98e99d31cff2cd", size = 1039759, upload-time = "2026-05-05T22:04:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d8/3c66cdf839420a6da2d6140a54a882c15efd135bcced103bd4473d577636/ujson-5.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8df5d984ff4ac1ef292d70f30da03417038a7e1e0bc272d28ca9d34f02f41682", size = 1199121, upload-time = "2026-05-05T22:04:31.961Z" }, + { url = "https://files.pythonhosted.org/packages/54/51/c3d1b94a4ad27dc7532e9f7d00b869463157cede2295ba6d57566afeb8cd/ujson-5.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:485f0182a0c0b54c304061cdc826d8343ce595c4055f7a24e72772a8520e5f7b", size = 1092085, upload-time = "2026-05-05T22:04:33.697Z" }, + { url = "https://files.pythonhosted.org/packages/ae/52/4d4a6e78290a5eef3f576f6d281e6355535db903a08483fd1bb393bf8cb9/ujson-5.12.1-cp314-cp314t-win32.whl", hash = "sha256:4e12ca368b397aed7fa1eec534ea1ba8d94977b376f9df3e93ae1acfd004ec40", size = 43243, upload-time = "2026-05-05T22:04:35.486Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c8/849366785de52b513e5fc89d7aea0b531e71bb5641407cbdfdf47a99ede8/ujson-5.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:cec6b9b539539affc1f01a795c99574592a635ce22331b64f2b42e0af570659e", size = 47662, upload-time = "2026-05-05T22:04:37.07Z" }, + { url = "https://files.pythonhosted.org/packages/8a/46/36a67f5a531a15308124786f3e2b7b96414b9d23dbcdc2a182dd3ffa2e1d/ujson-5.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:696224d4cfb8883fa5c0285dff31e5ce924704dd9ccd38e9ea8b5bf4a42b12fc", size = 41680, upload-time = "2026-05-05T22:04:39.083Z" }, + { url = "https://files.pythonhosted.org/packages/6d/26/c9d0479236b3f5690d6a8bb45f708aabc2c91ca80d275eba24b1e9e464ab/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c419bf42ae40963fc27f70c59e24e9a97f5cf168dbce2c572f3c0ce3595912", size = 56153, upload-time = "2026-05-05T22:04:40.326Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c8/785f4e132500aff2f1fd2bd4a4b86fe396a5519f830a098358c90ebb92ee/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0be2b4f2f547b9f0f3d902640e410e5a2fc851576cbe033c88445a23e3e7aef1", size = 57352, upload-time = "2026-05-05T22:04:42.005Z" }, + { url = "https://files.pythonhosted.org/packages/8f/13/b688a905653871b10b4ff0403c2ff562c17a0bd50be0d44324f3c85ca48f/ujson-5.12.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:4ea0c490c702c20495e97345acfcf0c2f3153e658ef537ff111929c48b89e10a", size = 45988, upload-time = "2026-05-05T22:04:43.36Z" }, +] + +[[package]] +name = "unstract-agent-kv-schema" +version = "0.1.0" +source = { editable = "../unstract/agent-kv-schema" } [[package]] name = "unstract-connectors" @@ -4958,6 +4963,7 @@ dependencies = [ { name = "python-socketio" }, { name = "redis" }, { name = "requests" }, + { name = "unstract-agent-kv-schema" }, { name = "unstract-connectors" }, { name = "unstract-core" }, { name = "unstract-filesystem" }, @@ -5009,6 +5015,7 @@ requires-dist = [ { name = "python-socketio", specifier = ">=5.9.0" }, { name = "redis", specifier = ">=4.5.0,<6.0.0" }, { name = "requests", specifier = ">=2.33.0,<3.0.0" }, + { name = "unstract-agent-kv-schema", editable = "../unstract/agent-kv-schema" }, { name = "unstract-connectors", editable = "../unstract/connectors" }, { name = "unstract-core", editable = "../unstract/core" }, { name = "unstract-filesystem", editable = "../unstract/filesystem" }, @@ -5074,45 +5081,45 @@ requires-dist = [ name = "uritemplate" version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267 } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488 }, + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, ] [[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "validators" version = "0.35.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/66/a435d9ae49850b2f071f7ebd8119dd4e84872b01630d6736761e6e7fd847/validators-0.35.0.tar.gz", hash = "sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a", size = 73399 } +sdist = { url = "https://files.pythonhosted.org/packages/53/66/a435d9ae49850b2f071f7ebd8119dd4e84872b01630d6736761e6e7fd847/validators-0.35.0.tar.gz", hash = "sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a", size = 73399, upload-time = "2025-05-01T05:42:06.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712 }, + { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712, upload-time = "2025-05-01T05:42:04.203Z" }, ] [[package]] name = "vine" version = "5.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636 }, + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, ] [[package]] name = "wcwidth" version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684 } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189 }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] [[package]] @@ -5128,67 +5135,67 @@ dependencies = [ { name = "pydantic" }, { name = "validators" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/0e/450732a620dec30e5c13d0e5ba6ea81cd2b168ffb5a1e6cfa034da2fd988/weaviate_client-4.20.1.tar.gz", hash = "sha256:7d3c4835292b17d54757c3f62921e87975c01f26869db787250cebef2a17bd80", size = 807802 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0e/450732a620dec30e5c13d0e5ba6ea81cd2b168ffb5a1e6cfa034da2fd988/weaviate_client-4.20.1.tar.gz", hash = "sha256:7d3c4835292b17d54757c3f62921e87975c01f26869db787250cebef2a17bd80", size = 807802, upload-time = "2026-02-25T13:39:56.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3a/a2c74bfbdc5f8acbab8025205520448dcece82942206a9861ec48ccad03f/weaviate_client-4.20.1-py3-none-any.whl", hash = "sha256:6ca36bb8752c39589bfc856c1232cbcb627f69141e7a741b592e2516888f59d8", size = 618735 }, + { url = "https://files.pythonhosted.org/packages/de/3a/a2c74bfbdc5f8acbab8025205520448dcece82942206a9861ec48ccad03f/weaviate_client-4.20.1-py3-none-any.whl", hash = "sha256:6ca36bb8752c39589bfc856c1232cbcb627f69141e7a741b592e2516888f59d8", size = 618735, upload-time = "2026-02-25T13:39:54.476Z" }, ] [[package]] name = "websocket-client" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 }, + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] [[package]] name = "wrapt" version = "1.17.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998 }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020 }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098 }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036 }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156 }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102 }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732 }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705 }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877 }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885 }, - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003 }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025 }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108 }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072 }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214 }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105 }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766 }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711 }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885 }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896 }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132 }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091 }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172 }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163 }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963 }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945 }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857 }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178 }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310 }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266 }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544 }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283 }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366 }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571 }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094 }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659 }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946 }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717 }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334 }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471 }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 }, +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]] @@ -5198,9 +5205,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405 }, + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] [[package]] @@ -5212,115 +5219,115 @@ dependencies = [ { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737 }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029 }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310 }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587 }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528 }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339 }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061 }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132 }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289 }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950 }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960 }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703 }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325 }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067 }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285 }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359 }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674 }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879 }, - { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796 }, - { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547 }, - { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854 }, - { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351 }, - { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711 }, - { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014 }, - { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557 }, - { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559 }, - { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502 }, - { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027 }, - { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369 }, - { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565 }, - { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813 }, - { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632 }, - { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895 }, - { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356 }, - { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515 }, - { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785 }, - { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719 }, - { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690 }, - { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851 }, - { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874 }, - { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710 }, - { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033 }, - { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817 }, - { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482 }, - { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949 }, - { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839 }, - { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696 }, - { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865 }, - { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234 }, - { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295 }, - { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784 }, - { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313 }, - { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932 }, - { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786 }, - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455 }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752 }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291 }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026 }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355 }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417 }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422 }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915 }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690 }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750 }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685 }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009 }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033 }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483 }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175 }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871 }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093 }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384 }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019 }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894 }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979 }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943 }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786 }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307 }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904 }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728 }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964 }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882 }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797 }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023 }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227 }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302 }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202 }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558 }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610 }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041 }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288 }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] [[package]] name = "zipp" version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] [[package]] name = "zipstream-ng" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/f2/690a35762cf8366ce6f3b644805de970bd6a897ca44ce74184c7b2bc94e7/zipstream_ng-1.9.0.tar.gz", hash = "sha256:a0d94030822d137efbf80dfdc680603c42f804696f41147bb3db895df667daea", size = 37963 } +sdist = { url = "https://files.pythonhosted.org/packages/11/f2/690a35762cf8366ce6f3b644805de970bd6a897ca44ce74184c7b2bc94e7/zipstream_ng-1.9.0.tar.gz", hash = "sha256:a0d94030822d137efbf80dfdc680603c42f804696f41147bb3db895df667daea", size = 37963, upload-time = "2025-08-29T01:03:36.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/62/c2da1c495291a52e561257d017585e08906d288035d025ccf636f6b9a266/zipstream_ng-1.9.0-py3-none-any.whl", hash = "sha256:31dc2cf617abdbf28d44f2e08c0d14c8eee2ea0ec26507a7e4d5d5f97c564b7a", size = 24852 }, + { url = "https://files.pythonhosted.org/packages/de/62/c2da1c495291a52e561257d017585e08906d288035d025ccf636f6b9a266/zipstream_ng-1.9.0-py3-none-any.whl", hash = "sha256:31dc2cf617abdbf28d44f2e08c0d14c8eee2ea0ec26507a7e4d5d5f97c564b7a", size = 24852, upload-time = "2025-08-29T01:03:35.046Z" }, ] From d6dc9e4b9092a7596c679a7172b357f530945aec Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 12:19:41 +0530 Subject: [PATCH 02/54] UN-4232 [FIX] Lock the cross-repo stage name; correct the API docs Task 5a and Task 6. THE STAGE NAME `TABLE_STAGE_NAMES[0]` (here) is the stage name the status endpoint will SHOW. `STAGE_TABLE_EXTRACTION` (cloud `agentic_table/src/api_binding.py`) is the name the executor actually SENDS. Nothing enforced their equality: StageReportView persists whatever arrives, and `_status_document` then filters a job's recorded stages through the list here. On drift the job completes normally, bills normally, and every status response returns an empty `stages` array. No test on either side could catch it -- each repo is internally consistent on its own -- and from a client's seat it reads as "the API is broken". Until now the only thing tying the two was a pair of comments. Both halves now assert the literal and name the other's file. A literal, not an import: the repos are separate checkouts that meet only in the merged tree, so an import would pass vacuously exactly where drift gets introduced. DOCS `docs/agent-kv-api.md` said the `name` field takes "`kv` or `table`". It now says `table` is the only supported value, with a note explaining why a `kv` submit returns 400 -- otherwise that refusal reads as a bug -- and that the wire format does not change when `kv` ships. The `/validate` section is removed along with its route, and every reference to it, and sections 7-13 are renumbered to 6-12 with their anchors and cross-links. Section 2 (the `kv` schema language) is kept but now opens by saying the extractor it describes is not routable here: the compiler ships and the language is frozen, so an integration can still be written against it. Co-Authored-By: Claude Opus 5 --- .../tests/test_cross_repo_stage_name.py | 43 +++++++++ docs/agent-kv-api.md | 90 ++++++++----------- 2 files changed, 80 insertions(+), 53 deletions(-) create mode 100644 backend/agent_kv/tests/test_cross_repo_stage_name.py diff --git a/backend/agent_kv/tests/test_cross_repo_stage_name.py b/backend/agent_kv/tests/test_cross_repo_stage_name.py new file mode 100644 index 0000000000..00255a7169 --- /dev/null +++ b/backend/agent_kv/tests/test_cross_repo_stage_name.py @@ -0,0 +1,43 @@ +"""The one constant that drifts silently across the two repos. + +``TABLE_STAGE_NAMES[0]`` here is the stage name ``_status_document`` will +*show*. ``STAGE_TABLE_EXTRACTION`` in the cloud repo's +``workers/plugins/agentic_table/src/api_binding.py`` is the name the executor +actually *sends*. They must be equal, and nothing enforces it: +``StageReportView`` persists whatever name arrives, and ``_status_document`` +then filters a job's recorded stages through the list below. + +So on drift the job completes normally, bills normally, and every status +response reports an empty ``stages`` array. No test on either side can catch +that alone -- each repo is internally consistent -- and from a client's seat it +reads as "the API is broken". This file is one half of the lock; the cloud half +is ``workers/plugins/agentic_table/tests/test_cross_repo_stage_name.py``. + +A literal, not an import: the two repos are separate checkouts and only meet in +the merged tree, so an import would make this test pass vacuously wherever the +other side is absent -- exactly the environments where drift is introduced. +""" + +from agent_kv.constants import TABLE_EXTRACTOR_NAME, TABLE_STAGE_NAMES + +#: Must equal ``STAGE_TABLE_EXTRACTION`` in the cloud repo's +#: ``workers/plugins/agentic_table/src/api_binding.py``. Change one, change +#: both, in the same PR. +CLOUD_STAGE_TABLE_EXTRACTION = "table_extraction" + + +def test_the_status_filter_matches_the_name_the_executor_sends(): + assert TABLE_STAGE_NAMES[0] == CLOUD_STAGE_TABLE_EXTRACTION, ( + "TABLE_STAGE_NAMES[0] no longer equals STAGE_TABLE_EXTRACTION in the " + "cloud repo's workers/plugins/agentic_table/src/api_binding.py. Every " + "table job will complete normally while reporting an empty `stages` " + "array. Update both sides, and the cloud twin of this test, together." + ) + + +def test_the_table_extractor_reports_exactly_one_stage(): + """The table engine exposes no node-level progress hooks, so a second name + here would describe progress the executor cannot substantiate -- it would + be recorded as missing rather than pending, for every job.""" + assert TABLE_STAGE_NAMES == [CLOUD_STAGE_TABLE_EXTRACTION] + assert TABLE_EXTRACTOR_NAME == "table" diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index a3d17308af..493e13083e 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -9,7 +9,7 @@ This document describes the API **as built in this repo** — the OSS scaffold ( validation, dispatch, job lifecycle, key management). The document, `keys.json` schema, and OCR/LLM extraction pipeline itself are described only insofar as they shape the contract; the extraction engine ships as a cloud executor plugin (see -[§11, 501 behavior](#11-when-the-engine-is-unavailable-501-behavior) below). +[§6, 501 behavior](#10-when-the-engine-is-unavailable-501-behavior) below). Design background: `docs/superpowers/specs/2026-08-28-agent-kv-api-design.md` (referred to as "the spec" throughout this document; section numbers below are spec section @@ -23,7 +23,7 @@ There are two independent auth schemes on two different URL prefixes: | Surface | Prefix | Auth | |---|---|---| -| Public execution API (submit/status/result/cancel/validate) | `/agent-kv/…` (top-level, not under the tenant subfolder) | `Authorization: Bearer ` — a dedicated per-organization key, validated by the view itself | +| Public execution API (submit/status/result/cancel) | `/agent-kv/…` (top-level, not under the tenant subfolder) | `Authorization: Bearer ` — a dedicated per-organization key, validated by the view itself | | Key management (create/list/rotate/revoke) | `{PATH_PREFIX}/unstract/…/agent-kv/keys/…` (tenant-scoped, alongside Platform API key management) | Session auth, `IsOrganizationAdmin` only | The execution prefix (`AGENT_KV_PATH_PREFIX`, default `agent-kv`) sits **outside** the @@ -68,10 +68,17 @@ not a v1 gap unique to this feature. ## 2. The extraction schema (`keys` field) +> **This section describes the `kv` extractor's schema language, which is not +> routable on this deployment** (see [§3](#3-submit--post-agent-kv-multipart)). +> It is documented here because the compiler ships and the language is frozen, +> so an integration can be written against it ahead of the extractor's release. +> The `table` extractor's `keys` is just `{"target_table": ""}` and needs +> none of this. + The `keys` submit field — spec-called "keys.json" — is a JSON object describing what to extract. It compiles through `unstract.agent_kv_schema.compile_schema` (`unstract/agent-kv-schema/src/unstract/agent_kv_schema/`), the **single schema compiler -in the system**: the OSS API validates with it at submit time and at `/validate`, and +in the system**: the OSS API validates with it at submit time, and the cloud engine imports the same package to execute — the schema language cannot drift between validation and execution by construction (spec §5.1). @@ -165,11 +172,10 @@ Enforced by `compile_schema` regardless of the byte-size cap below (`SchemaCaps` These caps are hardcoded in the compiler (not env-configurable); what **is** env-configurable is the raw byte size of the whole `keys` document (`AGENT_KV_MAX_SCHEMA_BYTES`) and of `calculations` -(`AGENT_KV_MAX_CALCULATIONS_BYTES`) — see [§13 env reference](#13-environment-reference). +(`AGENT_KV_MAX_CALCULATIONS_BYTES`) — see [§13 env reference](#12-environment-reference). A schema that fails to compile is rejected with a `400` at submit time — **nothing is -billed** — or, at `/validate`, with a `200` carrying `{"valid": false, "error": ""}` -(compile errors are not 400s there; only a missing `keys` body key is). +billed**. ## 3. Submit — `POST /agent-kv/` (multipart) @@ -196,10 +202,17 @@ The request is **extractor-scoped**: a per-extractor schema and its knobs live i | Field | Required | Notes | |---|---|---| -| `name` | yes | Which extractor: `kv` or `table`; anything else ⇒ 400. More than one entry ⇒ 400 — the format is in place for multi-extractor jobs, the execution is not built yet. | -| `keys` | yes | This extractor's own schema. For `kv`, its `keys.json` (see [§2](#2-the-extraction-schema-keys-field)); for `table`, `{"target_table": ""}` — the engine's one required extraction parameter. | +| `name` | yes | Which extractor. **`table` is the only supported value on this deployment**; anything else, `kv` included, ⇒ 400 (see the note below). More than one entry ⇒ 400 — the format is in place for multi-extractor jobs, the execution is not built yet. | +| `keys` | yes | This extractor's own request. For `table`, `{"target_table": ""}` — the engine's one required extraction parameter. | | `options` | no | This extractor's own knobs (below). An unrecognised option ⇒ 400, so a knob aimed at the wrong extractor fails loudly instead of being silently dropped. | +> **Why `kv` returns 400 here.** This deployment ships the table extractor only. +> The `kv` extractor's engine runs on its own release track and nothing on this +> deployment consumes its queue, so the API refuses a `kv` submit outright +> rather than accepting it with a `202` for a job that would never run. The +> wire format is unchanged: when `kv` ships, it becomes a second accepted value +> of `name` and nothing else about this document changes. + **`kv` options:** | Option | Notes | @@ -265,9 +278,9 @@ Not exposed (D6): model choice, challenger model, `parallel_pages`, thinking bud [result payload](#5-result--get-agent-kvjob_idresult), same shape as the result endpoint. -`501`: [engine unavailable](#11-when-the-engine-is-unavailable-501-behavior). +`501`: [engine unavailable](#10-when-the-engine-is-unavailable-501-behavior). `429`: rate-limited (per-key request rate, or per-org concurrent-job limit — see -[§13](#13-environment-reference)). +[§6](#12-environment-reference)). `402`: the organization's subscription does not permit the request — its trial has expired, or the subscription is inactive. Nothing is dispatched and nothing is billed. This is the same gate, the same policy and the same response bodies an @@ -509,36 +522,7 @@ and `parse_failures` appears only when some page failed to parse. There is **no > two settings of one option: a client that sets the flag must read `tables.rows` > instead of iterating `tables`. -## 6. Validate — `POST /agent-kv/validate` - -Compile-only check; **free of charge but not free of auth** — authenticated (valid key) -and rate-limited the same as submit (spec §6.1: "an open compile endpoint would be a -probing/DoS surface"). No job is created; nothing is billed either way. - -```bash -curl -X POST https://api.unstract.example/agent-kv/validate \ - -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" \ - -H "Content-Type: application/json" \ - -d '{"keys": {"invoice_number": {"description": "Invoice #", "required": true}}}' -``` - -Success (`200`): - -```json -{"valid": true, "leaves": 1, "arrays": 0, "constraints": 0} -``` - -Invalid schema — still `200`, not `400` (schema-compile rejection is a *response*, not -a request error): - -```json -{"valid": false, "error": "Leaf at invoice_number is missing a 'description'"} -``` - -Missing `keys` key in the body is the one case that *is* a `400`: -`{"detail": "body must include 'keys'"}`. - -## 7. Cancel — `POST /agent-kv/{job_id}/cancel` +## 6. Cancel — `POST /agent-kv/{job_id}/cancel` Defined precisely per spec §7.4: no task-revocation machinery exists anywhere in the platform, so cancel flips the job row to `CANCELLED` via the same guarded @@ -558,7 +542,7 @@ simply has its result discarded on arrival. - Won the race (job was non-terminal): `200` `{"status": "cancelled"}`. - Already terminal: `409` `{"status": ""}` — **note this one is the raw uppercase enum value** (e.g. `"COMPLETED"`), unlike every lowercased `status` value - elsewhere in this API (status doc, result 409, validate). This is the code as shipped + elsewhere in this API (status doc, result 409). This is the code as shipped (`execution_views.py::JobCancelView`, confirmed by `test_job_views.py::test_cancel_on_completed_is_409_and_result_untouched`), not a typo in this document. @@ -568,7 +552,7 @@ curl -X POST https://api.unstract.example/agent-kv/5b6e9b0a-.../cancel \ -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" ``` -## 8. Delete — `DELETE /agent-kv/{job_id}` +## 7. Delete — `DELETE /agent-kv/{job_id}` Deletes the result and any residual staged input immediately, and blanks both refs (`204`, no body). The job row itself is retained (audit trail); a later `result` fetch @@ -579,7 +563,7 @@ curl -X DELETE https://api.unstract.example/agent-kv/5b6e9b0a-.../ \ -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" ``` -## 9. Webhook delivery +## 8. Webhook delivery `webhook_url` (if given at submit) receives exactly one terminal-state POST: `{"job_id": "...", "status": "completed"|"failed"|"cancelled"}` — a fixed payload @@ -621,7 +605,7 @@ ide-callback worker waives both guards (http scheme and non-public host) so the e2e lane can deliver to a receiver on the compose host. Never set it in production. -## 10. Retention and TTL +## 9. Retention and TTL - **Input deletion is completion-triggered, not TTL-based** (spec D10: "uploaded document deleted on job completion"). `FinalizeView.post` @@ -641,7 +625,7 @@ production. `DELETE /agent-kv/{job_id}`. - **Result retention**: `AGENT_KV_RESULT_TTL_DAYS` (default **7** days, D10's engineering default) — results are re-readable until then, then swept by the - internal `ttl-cleanup` endpoint ([§12](#12-deploy-checklist)), which blanks + internal `ttl-cleanup` endpoint ([§6](#11-deploy-checklist)), which blanks `result_ref` (and `input_ref` too, covering the cancelled-job case above, or defensively for any input that somehow outlives completion). The job row itself is never deleted (audit trail persists past TTL, only the object-store payloads are @@ -654,7 +638,7 @@ production. under the full 7 days. Documented here as shipped behavior, not silently reconciled — flagged for anyone tightening D10 later. -## 11. When the engine is unavailable (501 behavior) +## 10. When the engine is unavailable (501 behavior) The Django backend cannot see the executor plugin registry (that lives in the workers process). Dispatching to a queue no worker consumes would hang, not error — so gating @@ -674,7 +658,7 @@ plugin is a cloud deliverable, spec §5.1/§11: "OSS scaffold merges dark"). Eve {"detail": "agent-kv engine not available on this deployment"} ``` -with HTTP `501`. `/validate`, key management, status/result/cancel/delete on +with HTTP `501`. Key management and status/result/cancel/delete on *already-existing* jobs are unaffected by this gate — only `SubmitView` probes it, since only submit needs the engine. @@ -704,21 +688,21 @@ On `noop: true`, the executor **must**: Step 2 is not optional. `FinalizeView`'s terminal-state guard (spec §5.4) correctly no-ops the status write for an already-terminal job — but its concurrency-slot release (`AgentKVConcurrencyLimiter.release`) lives in a `finally`, unconditional on -whether the guard actually won ([§10](#10-retention-and-ttl) covers the input-deletion +whether the guard actually won ([§6](#9-retention-and-ttl) covers the input-deletion side of this same guard). An executor that treats `noop: true` as "nothing left to do here" and skips the finalize call entirely leaks that job's concurrency slot for the rest of the limiter's TTL (6 hours) instead of releasing it immediately. This is the entire mechanism behind a pre-pickup cancel actually being observed and cleaned up promptly — there is no other signal. -## 12. Deploy checklist +## 11. Deploy checklist Everything below is required (or worth checking) to run this feature for real, beyond `docker compose up`: 1. **Cloud plugin**: install/enable the `agent_kv` backend capability plugin (probed via `plugins.get_plugin("agent_kv")`) — without it, submit always 501s - ([§11](#11-when-the-engine-is-unavailable-501-behavior)). This repo ships gated + ([§6](#10-when-the-engine-is-unavailable-501-behavior)). This repo ships gated dark by design; nothing to do for an OSS-only deployment except accept the 501. The marker the probe keys off is the mere presence of `backend/plugins/agent_kv/__init__.py` — a cloud image ships it (via the @@ -800,7 +784,7 @@ beyond `docker compose up`: ttl-cleanup additionally needs `storage` for `AGENT_KV_FILE_STORAGE_CREDENTIALS` (see item 6 below) — both already on the `backend` deployment's config list. 5. **Env vars**: every `AGENT_KV_*` setting plus `AGENT_KV_FILE_STORAGE_CREDENTIALS` — - see [§13](#13-environment-reference) and `docker/sample.env`. The executor-side vars + see [§6](#12-environment-reference) and `docker/sample.env`. The executor-side vars (read by the cloud `agentic_kv` plugin, not by the backend settings in §13) are a separate chart group: `global.sharedConfigs.agentKv` (`charts/unstract-platform/values.yaml`, cloud repo) — @@ -981,7 +965,7 @@ beyond `docker compose up`: `runtimeClass: gvisor` sandbox. Consequently the sandbox pod's default-deny egress and no-secrets posture are load-bearing and must not be relaxed. -## 13. Environment reference +## 12. Environment reference All `AGENT_KV_*` settings (`backend/backend/settings/base.py`), each `os.environ.get` with the default shown: @@ -993,7 +977,7 @@ with the default shown: | `AGENT_KV_MAX_PAGES` | `100` | Pre-OCR page cap for PDFs (§6.1). Excel has no pre-OCR page concept; images are not accepted. | | `AGENT_KV_MAX_CALCULATIONS_BYTES` | `20000` | Byte cap on the optional `calculations` field. | | `AGENT_KV_MAX_SCHEMA_BYTES` | `262144` | Byte cap on the raw `keys` JSON document (256 KiB). | -| `AGENT_KV_RESULT_TTL_DAYS` | `7` | Result retention window (and a cancelled job's input, which rides the same TTL — a completed/failed job's input is deleted immediately at finalize instead), stamped at submit time (see [§10](#10-retention-and-ttl)). | +| `AGENT_KV_RESULT_TTL_DAYS` | `7` | Result retention window (and a cancelled job's input, which rides the same TTL — a completed/failed job's input is deleted immediately at finalize instead), stamped at submit time (see [§6](#9-retention-and-ttl)). | | `AGENT_KV_MAX_TIMEOUT_SECONDS` | `300` | Upper bound on the submit `timeout` (synchronous-wait) field. | | `AGENT_KV_CONCURRENT_LIMIT` | `5` | Per-organization concurrent in-flight job cap (own Redis namespace, fails open on Redis errors). | | `AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE` | `60` | Per-key request rate limit (submit + validate), fails open on Redis errors. | From 7067790f5a038dfd4a96bc7406a138550f0527cc Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 13:36:23 +0530 Subject: [PATCH 03/54] UN-4232 [MISC] Clear the six SonarCloud cognitive-complexity findings All six were `python:S3776` on code this PR introduces to `main`. Every one is a pure extraction -- no behaviour change, and the existing suites are the proof (193 backend, 42 schema, unchanged and green throughout). execution_views.py SubmitView.post 16 -> 8 internal_views.py FinalizeView.post 23 -> ~7 kv_schema.py _walk 25 -> ~9 compile.py compile_schema 20 -> ~4 constraints.py _aggregate 18 -> ~9 constraints.py _truth 18 -> ~6 The extractions follow the shape each function already had: * `SubmitView.post` -- `_subscription_denial`, `_dispatch_or_fail` (which folds the two identical except-branches into one exit) and `_sync_wait_response`. * `FinalizeView.post` -- `_complete`, `_fail` and `_drop_staged_input`, which flattens a four-deep nest into a dispatch and one guard. * `_walk` -- `_walk_array` and `_append_leaf`, one per node kind; the function now only does the depth/shape guards and dispatches. * `compile_schema` -- `_enforce_shape_caps`, `_enforce_leaf_caps` and `_validated_constraints`, split along the three things it was checking. * `_aggregate` -- `_parse_agg_call` takes the fail-closed call validation and the path split. * `_truth` -- `_compare_chain` takes the chained-comparison loop. Verified under flake8-cognitive-complexity at `--max-cognitive-complexity=15`: clean across `backend/agent_kv/` and `unstract/agent-kv-schema/src/`. The two functions that still measure 14-15 on that checker were analysed by Sonar in this same PR and not flagged, so they score at or under the threshold on its algorithm; they are untouched rather than churned for no gain. Also drops an orphan missed in the e2e re-point: `_CALC_ENV_GATE` and `_skip_unless_calculations_declared` outlived the two calculation scenarios they gated, and referenced a sandbox worker this PR does not ship. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 139 ++++++++++-------- backend/agent_kv/internal_views.py | 104 +++++++------ tests/e2e/agent_kv/test_agent_kv_e2e.py | 25 ---- .../src/unstract/agent_kv_schema/compile.py | 23 ++- .../unstract/agent_kv_schema/constraints.py | 78 ++++++---- .../src/unstract/agent_kv_schema/kv_schema.py | 97 +++++++----- 6 files changed, 270 insertions(+), 196 deletions(-) diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index f2fbb324fc..2853a6dafb 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -104,6 +104,75 @@ def _fail_job_response(job, org_id: str, message: str, *, job_saved: bool) -> Re ) +def _subscription_denial(plugin, agent_kv_key, request) -> Response | None: + """Subscription admission (§6.6), or ``None`` when the org may proceed. + + A submit dispatches paid work, so it is gated exactly as an API deployment + execute is -- same policy, same 402 bodies -- via the cloud plugin's gate, + which calls the very ``SubscriptionHelper`` that cloud's + ``SubscriptionMiddleware`` calls. + + Why here and not in that middleware: it resolves the org from the URL + (``/deployment/api/{org_name}/...``). Agent-KV's URL carries no org segment + -- the org is inside the Bearer key -- so the middleware's + ``get_organization_id`` returns None for these requests, finds no + subscription row, and admits every one of them. The view is the first point + where the org is actually known. + + ``organization.organization_id`` is the org SLUG -- the CharField + ``Subscription.organization_id`` is keyed on, and what the deployment URL + supplies as ``org_name``. NOT ``agent_kv_key.organization_id``, which is the + Organization FK primary key: that matches no row, and the shared policy + reads "no row" as "nothing to enforce", so the gate would silently admit + everything while looking correctly wired. + """ + gate_factory = plugin.get("service_class") + if not gate_factory: # absent on a cloud build predating the gate + return None + return gate_factory().check(agent_kv_key.organization.organization_id, request) + + +def _dispatch_or_fail(job, org_id: str, entry: dict, options: dict) -> Response | None: + """Dispatch the job, or return the failure response. ``None`` means sent. + + Both failure paths terminalize the job and release the concurrency slot. + The bare ``except`` is belt-and-braces: ``dispatch_job`` wraps its own + internal failures as ``DispatchError``, but nothing here may rely on that + alone -- any other exception must still terminalize and release rather than + escape as an unhandled 500 with the slot still held. + """ + try: + dispatch_job(job, extractor=entry["name"], schema=entry["keys"], options=options) + except DispatchError: + logger.exception("agent-kv dispatch failed for job %s", job.id) + except Exception: + logger.exception("agent-kv dispatch raised unexpectedly for job %s", job.id) + else: + return None + return _fail_job_response( + job, org_id, "Job could not be dispatched; nothing was billed.", job_saved=True + ) + + +def _sync_wait_response(job, wait: float) -> Response | None: + """Poll until the job is terminal or ``wait`` elapses (§7.1 sync mode). + + Returns the full result payload if it terminalized in time, else ``None`` + so the caller falls through to the normal 202 handshake. + """ + if not wait: + return None + deadline = time.monotonic() + wait + while time.monotonic() < deadline: + job.refresh_from_db() + if job.status in AgentKVJob.TERMINAL: + from agent_kv.execution_views_result import result_payload + + return Response(result_payload(job), status=200) + time.sleep(1) + return None + + class SubmitView(APIView): authentication_classes: list = [] permission_classes: list = [] @@ -116,31 +185,9 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): if not check_key_rate(str(agent_kv_key.id)): raise RateLimited() - # Subscription admission (§6.6). A submit dispatches paid work, so it is - # gated exactly as an API deployment execute is -- same policy, same 402 - # bodies -- via the cloud plugin's gate, which calls the very - # `SubscriptionHelper` that cloud's `SubscriptionMiddleware` calls. - # - # Why here and not in that middleware: it resolves the org from the URL - # (`/deployment/api/{org_name}/...`). Agent-KV's URL carries no org - # segment -- the org is inside the Bearer key -- so the middleware's - # `get_organization_id` returns None for these requests, finds no - # subscription row, and admits every one of them. This is the first - # point where the org is actually known. - # - # `organization.organization_id` is the org SLUG -- the CharField - # `Subscription.organization_id` is keyed on, and what the deployment - # URL supplies as `org_name`. NOT `agent_kv_key.organization_id`, which - # is the Organization FK primary key: that matches no row, and the - # shared policy reads "no row" as "nothing to enforce", so the gate - # would silently admit everything while looking correctly wired. - gate_factory = plugin.get("service_class") - if gate_factory: # absent on a cloud build predating the gate - denied = gate_factory().check( - agent_kv_key.organization.organization_id, request - ) - if denied is not None: - return denied + denied = _subscription_denial(plugin, agent_kv_key, request) + if denied is not None: + return denied data = request.data.copy() part = data.get("extractors") @@ -188,44 +235,14 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): # range drives the shared OCR pass, so it cannot be per-extractor). options["page_start"] = v["page_start"] options["page_end"] = v["page_end"] - try: - dispatch_job( - job, extractor=entry["name"], schema=entry["keys"], options=options - ) - except DispatchError: - logger.exception("agent-kv dispatch failed for job %s", job.id) - return _fail_job_response( - job, - org_id, - "Job could not be dispatched; nothing was billed.", - job_saved=True, - ) - except Exception: - # Belt-and-braces: dispatch_job wraps its own internal failures - # as DispatchError, but nothing here may rely on that alone — - # any other exception must still terminalize the job and - # release the slot rather than escape as an unhandled 500. - logger.exception( - "agent-kv dispatch raised unexpectedly for job %s", - job.id, - ) - return _fail_job_response( - job, - org_id, - "Job could not be dispatched; nothing was billed.", - job_saved=True, - ) - wait = v["timeout"] - if wait: - deadline = time.monotonic() + wait - while time.monotonic() < deadline: - job.refresh_from_db() - if job.status in AgentKVJob.TERMINAL: - from agent_kv.execution_views_result import result_payload + failed = _dispatch_or_fail(job, org_id, entry, options) + if failed is not None: + return failed - return Response(result_payload(job), status=200) - time.sleep(1) + inline = _sync_wait_response(job, v["timeout"]) + if inline is not None: + return inline return Response( { diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index 744a571bf9..606b0af63a 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -150,6 +150,63 @@ class FinalizeView(APIView): authentication_classes: list = [] permission_classes: list = [] + @staticmethod + def _complete(job, job_id, org_id: str, body) -> bool: + """Persist the result and terminalize as COMPLETED. True if the guard won. + + ``write_result`` unavoidably runs *before* the ``mark_terminal`` guard + (the result has to exist to get a ``result_ref`` to pass in), so a + guard-loss right after the write -- a concurrent cancel or duplicate + finalize won instead -- leaves a result file with no ref anywhere + pointing at it. That orphan is deleted here rather than left to leak + past even TTL cleanup, which only ever acts on a job's *stored* + ``result_ref``. Best-effort; never blocks the response. + """ + result_ref = write_result(org_id, str(job_id), body.get("result") or {}) + finalized = AgentKVJob.mark_terminal( + job_id, + org_id, + JobStatus.COMPLETED, + result_ref=result_ref, + usage_summary=body.get("usage_summary"), + ) + if finalized: + job.status = JobStatus.COMPLETED + else: + delete_result_file(result_ref) + return finalized + + @staticmethod + def _fail(job, job_id, org_id: str, body) -> bool: + """Terminalize as FAILED. True if the guard won.""" + finalized = AgentKVJob.mark_terminal( + job_id, org_id, JobStatus.FAILED, error=body.get("error") or "" + ) + if finalized: + job.status = JobStatus.FAILED + return finalized + + @staticmethod + def _drop_staged_input(job, job_id, org_id: str) -> None: + """Delete the staged input now the run is over (spec D10). + + Only ever called when ``mark_terminal`` actually won, so it never runs + on the duplicate/guard-lost path -- there, either another writer owns + cleanup or there is nothing new to terminalize. A CANCELLED job never + reaches here either: cancel goes through JobCancelView/mark_terminal + directly, and a late finalize against an already-CANCELLED job loses + the terminal guard, so a cancelled job's input rides the normal TTL + sweep instead. + + The ref is blanked only when the file is confirmed gone -- otherwise it + is the only handle TTL cleanup could retry from, and blanking it would + orphan the uploaded document. + """ + if delete_input(job): + AgentKVJob.objects.filter(id=job_id, organization_id=org_id).update( + input_ref="" + ) + def post(self, request, *args, job_id=None, **kwargs): body = request.data org_id = body.get("org_id") @@ -166,51 +223,10 @@ def post(self, request, *args, job_id=None, **kwargs): finalized = False try: if job is not None and job.status not in AgentKVJob.TERMINAL: - if success: - result_ref = write_result( - org_id, str(job_id), body.get("result") or {} - ) - finalized = AgentKVJob.mark_terminal( - job_id, - org_id, - JobStatus.COMPLETED, - result_ref=result_ref, - usage_summary=body.get("usage_summary"), - ) - if finalized: - job.status = JobStatus.COMPLETED - else: - # Guard lost after the write above (e.g. a cancel - # raced in between the read and mark_terminal's - # guarded UPDATE): the result we just wrote has no - # ref anywhere -- unreachable forever unless cleaned - # up here. Best-effort; never blocks the response. - delete_result_file(result_ref) - else: - finalized = AgentKVJob.mark_terminal( - job_id, - org_id, - JobStatus.FAILED, - error=body.get("error") or "", - ) - if finalized: - job.status = JobStatus.FAILED - + handler = self._complete if success else self._fail + finalized = handler(job, job_id, org_id, body) if finalized: - # A CANCELLED job never reaches here: cancel goes - # through JobCancelView/mark_terminal directly, not - # this finalize path, and a late finalize call against - # an already-CANCELLED job loses the terminal guard - # above -- so a cancelled job's input intentionally - # rides the normal TTL sweep instead of being deleted - # here. - # Blank the ref only when the file is confirmed gone -- - # otherwise it is the only handle TTL cleanup can retry - # from, and blanking it orphans the uploaded document. - if delete_input(job): - AgentKVJob.objects.filter( - id=job_id, organization_id=org_id - ).update(input_ref="") + self._drop_staged_input(job, job_id, org_id) finally: AgentKVConcurrencyLimiter.release(org_id, str(job_id)) diff --git a/tests/e2e/agent_kv/test_agent_kv_e2e.py b/tests/e2e/agent_kv/test_agent_kv_e2e.py index b04578cea6..edfff5dbf0 100644 --- a/tests/e2e/agent_kv/test_agent_kv_e2e.py +++ b/tests/e2e/agent_kv/test_agent_kv_e2e.py @@ -618,31 +618,6 @@ def log_message(self, *args): # keep pytest output pristine thread.join(timeout=5) -# --------------------------------------------------------------------------- -# 9e. Calculations (sandbox worker, operator-gated) -# --------------------------------------------------------------------------- - -_CALC_ENV_GATE = "AGENT_KV_E2E_CALCULATIONS" - - -def _skip_unless_calculations_declared() -> None: - """Calculations need a stack running with ``AGENT_KV_CALCULATIONS_ENABLED - =true`` on the backend AND the sandbox codegen worker fleet actually up - and consuming ``sandbox_codegen`` (docs/agent-kv-api.md §11) -- neither - of which this test process can see or arrange for itself. So, like the - bad-LLM-key scenario above, it needs an explicit operator declaration - that the stack under test is configured that way; not every stack that - runs the rest of this lane has the sandbox worker running. - """ - if os.environ.get(_CALC_ENV_GATE) != "1": - pytest.skip( - "calculation e2e scenarios need a stack running with " - "AGENT_KV_CALCULATIONS_ENABLED=true and the sandbox codegen " - f"worker fleet up (docs/agent-kv-api.md §11); set {_CALC_ENV_GATE}=1 " - "to declare that this stack is configured that way" - ) - - def test_concurrency_limit_returns_429(agent_kv_key: AgentKVAuth) -> None: """Saturate the org's concurrency limiter; every other submit-based scenario in this module runs before this one, and ``tests/groups.yaml`` diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py index 6b28c3b07f..5eb2c9bb1e 100644 --- a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py @@ -226,6 +226,17 @@ def compile_schema(spec: dict, caps: SchemaCaps | None = None) -> CompiledSchema except ValueError as e: raise SchemaError(str(e)) from e + _enforce_shape_caps(key_specs, array_specs, caps) + _enforce_leaf_caps(key_specs, array_specs, caps) + constraints = _validated_constraints(spec, caps) + + return CompiledSchema( + key_specs=key_specs, array_specs=array_specs, constraints=list(constraints) + ) + + +def _enforce_shape_caps(key_specs, array_specs, caps: SchemaCaps) -> None: + """Bound how much structure the schema declares.""" if len(key_specs) > caps.max_leaves: raise SchemaError(f"schema exceeds max_leaves={caps.max_leaves}") if len(array_specs) > caps.max_arrays: @@ -236,6 +247,10 @@ def compile_schema(spec: dict, caps: SchemaCaps | None = None) -> CompiledSchema f"array '{aspec.path}' exceeds " f"max_columns_per_array={caps.max_columns_per_array}" ) + + +def _enforce_leaf_caps(key_specs, array_specs, caps: SchemaCaps) -> None: + """Bound every leaf's author-supplied text, across scalars and array columns.""" for kspec in key_specs + [s for a in array_specs for s in a.item_specs]: _reject_unsafe_regex(kspec.path, kspec.regex_pattern) if len(kspec.regex_pattern) > caps.max_regex_len: @@ -250,6 +265,9 @@ def compile_schema(spec: dict, caps: SchemaCaps | None = None) -> CompiledSchema f"max_description_len={caps.max_description_len}" ) + +def _validated_constraints(spec: dict, caps: SchemaCaps) -> list: + """Return the schema's `_constraints`, rejecting a malformed or oversized list.""" constraints = spec.get("_constraints", []) if not isinstance(constraints, list) or not all( isinstance(c, str) for c in constraints @@ -259,7 +277,4 @@ def compile_schema(spec: dict, caps: SchemaCaps | None = None) -> CompiledSchema raise SchemaError(f"schema exceeds max_constraints={caps.max_constraints}") for expr in constraints: _check_constraint_syntax(expr) - - return CompiledSchema( - key_specs=key_specs, array_specs=array_specs, constraints=list(constraints) - ) + return constraints diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py index 85efa58b2e..8c93096dba 100644 --- a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py @@ -108,6 +108,33 @@ def _coerce(raw: str): return n if n is not None else raw +def _parse_agg_call(node: ast.Call) -> tuple[str, str, str]: + """Validate `NAME('literal')` and split it into (fn, array_path, column). + + FAIL-CLOSED by construction: the only thing accepted is `NAME('literal')` + where NAME is in `_AGG`, the func is a bare `ast.Name` (no attribute access + -- blocks `os.system` / `x.__class__`), there is exactly one positional arg, + no keywords, and that arg is a string Constant. Any deviation raises `_Skip`, + so the constraint is skipped and never run. + """ + if not isinstance(node.func, ast.Name) or node.func.id not in _AGG: + raise _Skip() # not a whitelisted aggregate name + if node.keywords or len(node.args) != 1: + raise _Skip() # exactly one positional arg, no keywords + arg = node.args[0] + if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)): + raise _Skip() # arg must be a literal string path + ref = arg.value + # Split on the LAST dot: an ArraySpec.path may itself be dotted (e.g. + # 'invoice.lines'), while the column is always a row-LOCAL bare name. A bare + # ref (no dot) is a whole-array row count -> array_path=ref, column=''. + if "." in ref: + array_path, _, column = ref.rpartition(".") + else: + array_path, column = ref, "" + return node.func.id, array_path, column + + def _aggregate(node: ast.Call, arrays: dict[str, list[dict[str, str]]]): """Evaluate one of the five allowlisted aggregates over an array column. @@ -123,21 +150,7 @@ def _aggregate(node: ast.Call, arrays: dict[str, list[dict[str, str]]]): sku/description count too); `count('a')` is the row count. A sum/min/max/avg over zero usable cells raises `_Skip` (advisory) rather than guessing 0. """ - if not isinstance(node.func, ast.Name) or node.func.id not in _AGG: - raise _Skip() # not a whitelisted aggregate name - if node.keywords or len(node.args) != 1: - raise _Skip() # exactly one positional arg, no keywords - arg = node.args[0] - if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)): - raise _Skip() # arg must be a literal string path - fn, ref = node.func.id, arg.value - # Split on the LAST dot: an ArraySpec.path may itself be dotted (e.g. 'invoice.lines'), - # while the column is always a row-LOCAL bare name. A bare ref (no dot) is a whole-array - # row count -> array_path=ref, column=''. - if "." in ref: - array_path, _, column = ref.rpartition(".") - else: - array_path, column = ref, "" + fn, array_path, column = _parse_agg_call(node) rows = arrays.get(array_path) if rows is None: # no such array available raise _Skip() @@ -207,21 +220,32 @@ def _truth(node, values: dict[str, str], arrays: dict[str, list[dict[str, str]]] if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): return not _truth(node.operand, values, arrays) if isinstance(node, ast.Compare) and len(node.ops) == len(node.comparators): - left = _operand(node.left, values, arrays) - for op, comp in zip(node.ops, node.comparators, strict=False): - if type(op) not in _CMP: - raise _Skip() - right = _operand(comp, values, arrays) - # only compare like-typed operands (number<->number, str<->str); else skip - if isinstance(left, (int, float)) != isinstance(right, (int, float)): - raise _Skip() - if not _CMP[type(op)](left, right): - return False - left = right - return True + return _compare_chain(node, values, arrays) raise _Skip() +def _compare_chain( + node: ast.Compare, values: dict[str, str], arrays: dict[str, list[dict[str, str]]] +) -> bool: + """Evaluate a (possibly chained) comparison, Python-style: `a < b < c`. + + Each link is checked against the previous operand, so the chain short- + circuits on the first False exactly as the language does. + """ + left = _operand(node.left, values, arrays) + for op, comp in zip(node.ops, node.comparators, strict=False): + if type(op) not in _CMP: + raise _Skip() + right = _operand(comp, values, arrays) + # only compare like-typed operands (number<->number, str<->str); else skip + if isinstance(left, (int, float)) != isinstance(right, (int, float)): + raise _Skip() + if not _CMP[type(op)](left, right): + return False + left = right + return True + + def _evaluate_one( expr: str, values: dict[str, str], arrays: dict[str, list[dict[str, str]]] ): diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/kv_schema.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/kv_schema.py index 93cedeb508..04235763d2 100644 --- a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/kv_schema.py +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/kv_schema.py @@ -75,40 +75,7 @@ def _walk( ) if "_array" in node: - extra = set(node.keys()) - RESERVED_NODE - if extra: - raise ValueError( - f"Array node at {'.'.join(path_parts) or ''} has unexpected keys {sorted(extra)}; " - f"allowed: {sorted(RESERVED_NODE)}" - ) - item_schema = node["_array"] - if not isinstance(item_schema, dict) or not item_schema: - raise ValueError( - f"Array item schema at {'.'.join(path_parts)} must be a non-empty object" - ) - item_specs: list[KeySpec] = [] - for col_name, col_node in item_schema.items(): - if isinstance(col_node, dict) and "_array" in col_node: - raise ValueError( - f"Nested array at {'.'.join(path_parts)}.{col_name} is P8b (not supported in P8a)" - ) - _walk( - col_node, - [col_name], - item_specs, - [], # row-LOCAL paths; nested arrays sink to [] (rejected above) - max_depth=max_depth, - depth=depth + 1, - ) - arrays.append( - ArraySpec( - path=".".join(path_parts), - description=str(node.get("description", "")), - item_specs=item_specs, - key_column=str(node.get("_key", "")), - dedup_rows=_parse_dedup(node.get("_dedup", True), ".".join(path_parts)), - ) - ) + _walk_array(node, path_parts, arrays, max_depth=max_depth, depth=depth) return object_values = [v for v in node.values() if isinstance(v, dict)] @@ -135,7 +102,67 @@ def _walk( ) return - # Leaf node: scalar attributes only. + _append_leaf(node, path_parts, scalar_keys, out) + + +def _walk_array( + node: dict[str, Any], + path_parts: list[str], + arrays: list[ArraySpec], + *, + max_depth: int, + depth: int, +) -> None: + """Compile an `_array` node into one ArraySpec. + + Column paths are row-LOCAL: each column walks with its own name as the + whole path and an empty `arrays` sink, so a nested array would have nowhere + to land -- which is why it is rejected explicitly first rather than left to + disappear into that sink. + """ + extra = set(node.keys()) - RESERVED_NODE + if extra: + raise ValueError( + f"Array node at {'.'.join(path_parts) or ''} has unexpected keys {sorted(extra)}; " + f"allowed: {sorted(RESERVED_NODE)}" + ) + item_schema = node["_array"] + if not isinstance(item_schema, dict) or not item_schema: + raise ValueError( + f"Array item schema at {'.'.join(path_parts)} must be a non-empty object" + ) + item_specs: list[KeySpec] = [] + for col_name, col_node in item_schema.items(): + if isinstance(col_node, dict) and "_array" in col_node: + raise ValueError( + f"Nested array at {'.'.join(path_parts)}.{col_name} is P8b (not supported in P8a)" + ) + _walk( + col_node, + [col_name], + item_specs, + [], + max_depth=max_depth, + depth=depth + 1, + ) + arrays.append( + ArraySpec( + path=".".join(path_parts), + description=str(node.get("description", "")), + item_specs=item_specs, + key_column=str(node.get("_key", "")), + dedup_rows=_parse_dedup(node.get("_dedup", True), ".".join(path_parts)), + ) + ) + + +def _append_leaf( + node: dict[str, Any], + path_parts: list[str], + scalar_keys: list[str], + out: list[KeySpec], +) -> None: + """Compile a leaf node (scalar attributes only) into one KeySpec.""" unknown = [k for k in scalar_keys if k not in RESERVED] if unknown: raise ValueError( From 2f44cc4530bbb6836ce9656d0f5af2a7fdf2fc70 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 13:46:01 +0530 Subject: [PATCH 04/54] UN-4232 [FIX] DELETE on a lost terminal race blanked the winner's result_ref Greptile P1 on PR #2317. Real, and it is data loss. The race: DELETE reads a RUNNING job whose `result_ref` is still "", the finalize callback wins the guarded UPDATE in between and writes COMPLETED plus a real `result_ref`, and `mark_terminal` here returns False. Execution then continued against the STALE in-memory job. `delete_job_files` reports an already-empty ref as "cleared" -- correctly, there was nothing to delete -- so the stale `result_ref: ""` came back in `cleared`, and `job.save(update_fields=cleared)` wrote "" over the ref the callback had just persisted. The outcome: a job that reports COMPLETED, a result that 404s (`JobResultView` reads COMPLETED-without-a-ref as swept), and the result object orphaned in the bucket with nothing pointing at it -- TTL cleanup selects on `result_ref > ""`, so a blanked row never comes back as a candidate. Fix: re-read the row when the terminal race is lost, before touching files. Deleting is still the caller's intent; it just has to act on the refs that actually exist rather than on a copy that predates the winner's write. Only on the lost-race path -- winning means nothing else wrote to the row, so the in-memory copy is current and the extra query would be waste on the common path. Both directions are asserted. Mutation-checked: replacing the refresh with `pass` fails `test_delete_refreshes_the_job_when_it_loses_the_terminal_race`. One existing test needed a stub: this suite touches no database, so the new read is mocked in `test_delete_does_not_release_the_slot_when_it_loses_the_terminal_race`. 195 passed. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 17 +++++ backend/agent_kv/tests/test_job_views.py | 88 +++++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index 2853a6dafb..d0ae1ee6c4 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -331,6 +331,23 @@ def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): # the winner is still accounting for; release() is idempotent # (zrem), so the winner's own release stays correct. AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) + else: + # Lost the race: a finalize or cancel terminalized this job + # between our read above and the guarded UPDATE. `job` is now + # STALE, and `result_ref` is the field that matters -- a winning + # finalize has just written one. + # + # Without this refresh the cleanup below runs against the stale + # copy, where `result_ref` is still "". `delete_job_files` + # reports an already-empty ref as "cleared" (nothing to delete), + # so the save then writes "" OVER the winner's real ref. The job + # stays COMPLETED, its result 404s (`JobResultView` treats + # COMPLETED-without-a-ref as swept), and the object is orphaned + # in the bucket with nothing left pointing at it -- TTL cleanup + # selects on `result_ref > ""`, so a blanked row never comes + # back. Deleting is still the caller's intent; it just has to + # act on the refs that actually exist now. + job.refresh_from_db() # Blank only the refs whose files are confirmed gone, so a ref whose # delete failed survives as the handle TTL cleanup retries from. 204 # either way: the job IS terminal and the caller's intent is recorded, diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index a070af8d76..fa2dded333 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -411,12 +411,15 @@ def test_delete_on_running_job_releases_the_concurrency_slot( # --------------------------------------------------------------------------- @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") +# A lost race re-reads the row before cleanup (see 8i); this suite touches no +# database, so the read itself is stubbed out here. +@mock.patch.object(AgentKVJob, "refresh_from_db") @mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") def test_delete_does_not_release_the_slot_when_it_loses_the_terminal_race( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_refresh, m_save, m_release ): m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.RUNNING) @@ -511,3 +514,86 @@ def test_all_job_views_401_without_key(): req = getattr(APIRequestFactory(), method)("/agent-kv/x") resp = view.as_view()(req, job_id=uuid.uuid4()) assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# (8i) Losing the terminal race must RE-READ the job before touching its files. +# +# The race: DELETE reads a RUNNING job (result_ref ""), the finalize callback +# wins the guarded UPDATE in between and writes COMPLETED + a real result_ref, +# and `mark_terminal` here returns False. Without a refresh the cleanup below +# runs against the stale copy: `delete_job_files` reports an already-empty +# `result_ref` as "cleared" (there was nothing to delete), and the save then +# writes "" OVER the winner's real ref. The job stays COMPLETED, its result +# 404s, and the object is orphaned -- TTL cleanup selects on `result_ref > ""`, +# so a blanked row never comes back. +# +# Reported by Greptile on PR #2317. +# --------------------------------------------------------------------------- +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_refreshes_the_job_when_it_loses_the_terminal_race( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING) + job.organization_id = "org1" + m_jobs.get.return_value = job + + # The winner's write, applied by refresh_from_db: COMPLETED + a real ref. + def _win_the_race(): + job.status = JobStatus.COMPLETED + job.result_ref = "org1/job/result.json" + + with mock.patch.object( + AgentKVJob, "refresh_from_db", side_effect=_win_the_race + ) as m_refresh: + # Capture what the cleanup actually SAW. `call_args` would be useless + # here: it records a reference to the same job object the view then + # blanks, so by assertion time it reads "" whether the fix works or not. + seen: list[str] = [] + + def _record(j): + seen.append(j.result_ref) + return ["input_ref", "result_ref"] if j.result_ref else ["input_ref"] + + m_delete_files.side_effect = _record + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + assert m_refresh.called, ( + "a lost terminal race must re-read the job; acting on the stale copy " + "blanks the winner's result_ref and orphans the result file" + ) + # The file helper saw the winner's ref, so the real object is deleted... + assert seen == ["org1/job/result.json"] + # ...and the blanking is of a ref that was genuinely cleared, not a stale "". + assert m_save.call_args.kwargs["update_fields"] == ["input_ref", "result_ref"] + + +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_does_not_refresh_when_it_wins_the_terminal_race( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release +): + """Winning means nothing else wrote to the row, so the in-memory copy is + current and the extra query would be waste on the common path. + """ + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING) + job.organization_id = "org1" + m_jobs.get.return_value = job + + with mock.patch.object(AgentKVJob, "refresh_from_db") as m_refresh: + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + assert not m_refresh.called From d01cb1785ee082f9687e8968059d2d9b13e4cc8c Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 14:26:34 +0530 Subject: [PATCH 05/54] UN-4232 [FIX] Four review findings: gate fail-open, unscheduled sweeps, page cap, cancel webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four traced and confirmed before changing anything. 1. SUBSCRIPTION GATE FAILED OPEN (`execution_views.py`) A plugin without `service_class` skipped the check and continued to dispatch. Reachable only on a cloud image predating the gate -- but the admitted request spends money, and this route's URL carries no org segment, so `SubscriptionMiddleware` cannot catch it downstream either: a mixed deploy ran unmetered paid work with nothing anywhere enforcing entitlement. Now refuses, via a new `SubscriptionGateUnavailable` (503). Deliberately not a 402 -- the subscription was never evaluated, and reporting it as denied would send an operator to the billing system for what is an image-pairing problem. `test_plugin_without_a_service_class_still_proceeds` is inverted to `..._is_refused`; it pinned the old behaviour as a contract, so it had to change rather than be deleted. 13 other submit tests now carry an admitting gate. 2. NOTHING SCHEDULED THE SWEEP OR THE TTL CLEANUP (migration 0004) Both tasks registered, both endpoints live, no `PgPeriodicTask` row anywhere -- the module docstring said an operator registers them by hand, which means they never ran. So an OOM-killed executor left its row non-terminal forever (`link_error` never fires for a killed process) holding a concurrency slot until the 6h Redis TTL, and `AGENT_KV_RESULT_TTL_DAYS` was advisory: every customer document retained indefinitely. Data migration following the dashboard_metrics precedent (0004-0006): same table, `update_or_create` so a re-run is idempotent, `pg_owned: False` so applying it does not itself start firing them. Sweep every 10 minutes (a stuck job holds a slot); TTL cleanup daily at 03:17, off the hour and off the metrics cleanups so two bulk deletes do not share a tick. The new test pins the migration's `task_name`s against the worker's registered wire names as literals -- an import would pass vacuously in the backend venv, which is exactly where the two sides drift. 3. PAGE CAP COUNTED THE DOCUMENT, NOT THE REQUEST (`execution_serializers.py`) Asking for pages 1-5 of a 400-page PDF was refused against a 100-page cap, despite requesting five pages of work. The cap now bounds the selected range. `pages_total` is unchanged -- it is what metering and the status document report -- and a new `pages_selected` carries what the cap compares. Also rejects a `page_start` past the end of the document, which previously selected nothing and billed for a job over zero pages. 4. CANCELLATION NEVER SENT THE DOCUMENTED WEBHOOK (`dispatch.py`, both cancel paths) Cancellation does not reach finalize, so no webhook fired; and a late executor callback could not send one either -- it loses the terminal guard, and `_maybe_webhook` correctly declines a non-fresh finalize. A caller who supplied `webhook_url` was simply never told, against docs §8. New `agent_kv_cancelled` worker task, routed on the existing callback queue, and enqueued by the backend from both `JobCancelView` and the DELETE-side cancel. Guarded on `won`, which is what makes the two paths mutually exclusive: a cancel that lost means a finalize won and will send. Exactly one path owns the notification. Best-effort enqueue -- the job is already cancelled and the caller already has their 200, so a lost notification logs rather than failing their request. The SSRF waiver moved into one `_send_webhook` helper so the cancel path cannot drift from the finalize path on what it permits. 211 backend tests pass (up from 195), plus ide_callback 18, registry 3, scheduler 7, task-imports 4, queue wiring 9. ruff clean. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/dispatch.py | 43 +++++++ backend/agent_kv/exceptions.py | 17 +++ backend/agent_kv/execution_serializers.py | 31 +++++- backend/agent_kv/execution_views.py | 42 ++++++- .../migrations/0004_pg_periodic_tasks.py | 97 ++++++++++++++++ backend/agent_kv/tests/test_job_views.py | 80 +++++++++++++ .../tests/test_periodic_tasks_migration.py | 105 ++++++++++++++++++ .../agent_kv/tests/test_submit_serializer.py | 45 ++++++++ backend/agent_kv/tests/test_submit_view.py | 61 +++++++--- workers/ide_callback/agent_kv_tasks.py | 65 +++++++++-- .../shared/infrastructure/config/registry.py | 4 + 11 files changed, 555 insertions(+), 35 deletions(-) create mode 100644 backend/agent_kv/migrations/0004_pg_periodic_tasks.py create mode 100644 backend/agent_kv/tests/test_periodic_tasks_migration.py diff --git a/backend/agent_kv/dispatch.py b/backend/agent_kv/dispatch.py index 54166bc7ad..d2d8348686 100644 --- a/backend/agent_kv/dispatch.py +++ b/backend/agent_kv/dispatch.py @@ -20,6 +20,49 @@ class DispatchError(Exception): """Enqueue failed; the caller terminalizes the job (spec §5.3).""" +def dispatch_cancelled_webhook(job) -> None: + """Queue the terminal webhook for a job the API just cancelled. + + Cancellation never reaches finalize -- ``JobCancelView`` and DELETE + terminalize the row themselves -- so without this the caller who supplied + ``webhook_url`` is never told, and a late executor callback cannot tell them + either (it loses the terminal guard, and the callback declines a non-fresh + finalize rather than double-notifying). Docs §8 promises delivery on + terminal states; this is the cancel half of that promise. + + Call ONLY when the guarded cancel actually won. That is what makes the two + paths mutually exclusive: a cancel that lost means a finalize won and will + send, and a cancel that won means no fresh finalize can. + + Best-effort by design. The job is already cancelled and the caller already + has their 200; failing their request because a notification could not be + QUEUED would be the wrong trade, so this logs and returns rather than + raising. Delivery itself is the worker's problem. + """ + if not job.webhook_url: + return + try: + from pg_queue.producer import enqueue_task + + enqueue_task( + task_name="agent_kv_cancelled", + queue=CALLBACK_QUEUE, + kwargs={ + "callback_kwargs": { + "job_id": str(job.id), + "webhook_url": job.webhook_url, + } + }, + org_id=str(job.organization_id), + ) + except Exception: + logger.exception( + "agent-kv: could not queue the cancellation webhook for job %s; " + "the job IS cancelled, only the notification was lost", + job.id, + ) + + def _dispatcher(): # No `celery_app`: UN-4046 removed that parameter when the routing # dispatcher's Celery branch went with the pg_queue_enabled flag. Passing it diff --git a/backend/agent_kv/exceptions.py b/backend/agent_kv/exceptions.py index 459821d2ef..6c84a9dd19 100644 --- a/backend/agent_kv/exceptions.py +++ b/backend/agent_kv/exceptions.py @@ -14,3 +14,20 @@ class RateLimited(APIException): class JobNotFound(APIException): status_code = 404 default_detail = "Job not found" + + +class SubscriptionGateUnavailable(APIException): + """The engine plugin is installed but exposes no subscription gate. + + Deliberately NOT a 402: the org's subscription was never evaluated, so + claiming it was denied would be a lie. This is a deployment fault — a build + that can run billable work but cannot check entitlement — and it fails + CLOSED, because the alternative is admitting unmetered paid work on a route + whose URL carries no org segment for the middleware to fall back on. + """ + + status_code = 503 + default_detail = ( + "agent-kv cannot verify subscription entitlement on this deployment; " + "the engine plugin exposes no subscription gate" + ) diff --git a/backend/agent_kv/execution_serializers.py b/backend/agent_kv/execution_serializers.py index 38e845b497..974adaf186 100644 --- a/backend/agent_kv/execution_serializers.py +++ b/backend/agent_kv/execution_serializers.py @@ -239,7 +239,13 @@ class SubmitSerializer(serializers.Serializer): #: ``{extractor_name: compiled schema}`` -- populated during validation. compiled = None + #: Measured page count of the uploaded document. None for Excel, which has + #: no pre-OCR page concept. This is what metering and the status document + #: report, and it is NOT what the page cap is compared against. pages_total = None + #: How many pages the request actually asks to process, after `page_start` / + #: `page_end` are applied. This is what the cap bounds. None for Excel. + pages_selected = None def validate_file(self, f): name = (f.name or "").lower() @@ -311,11 +317,30 @@ def validate(self, data): raise serializers.ValidationError({"file": "Unreadable PDF"}) finally: f.seek(0) - if self.pages_total > settings.AGENT_KV_MAX_PAGES: + if start > self.pages_total: raise serializers.ValidationError( { - "file": f"Document has {self.pages_total} pages; " - f"max is {settings.AGENT_KV_MAX_PAGES} (§6.1)" + "page_start": f"page_start {start} is past the end of a " + f"{self.pages_total}-page document" + } + ) + # The cap bounds the work the job will DO, not the size of the file + # it was handed. A caller asking for pages 1-5 of a 400-page PDF is + # requesting five pages of OCR and extraction; refusing that against + # a 100-page cap rejected a request that was inside the documented + # limit. `pages_total` stays the measured document count -- it is + # what metering and the status document report -- and only the cap + # comparison moves to the selected range. + last_page = self.pages_total if end is None else min(end, self.pages_total) + self.pages_selected = last_page - start + 1 + if self.pages_selected > settings.AGENT_KV_MAX_PAGES: + raise serializers.ValidationError( + { + "file": f"Requested {self.pages_selected} pages " + f"(page_start={start}, page_end=" + f"{end if end is not None else self.pages_total}) of a " + f"{self.pages_total}-page document; max is " + f"{settings.AGENT_KV_MAX_PAGES} (§6.1)" } ) # Excel: no page concept pre-OCR (spec §6.1); pages_total stays None, diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index d0ae1ee6c4..b6b476af0c 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -9,8 +9,13 @@ from rest_framework.views import APIView from agent_kv.constants import STAGE_NAMES_BY_EXTRACTOR -from agent_kv.dispatch import DispatchError, dispatch_job -from agent_kv.exceptions import EngineUnavailable, JobNotFound, RateLimited +from agent_kv.dispatch import DispatchError, dispatch_cancelled_webhook, dispatch_job +from agent_kv.exceptions import ( + EngineUnavailable, + JobNotFound, + RateLimited, + SubscriptionGateUnavailable, +) from agent_kv.execution_serializers import SubmitSerializer from agent_kv.execution_views_result import result_payload from agent_kv.key_validator import AgentKVKeyValidator @@ -125,10 +130,31 @@ def _subscription_denial(plugin, agent_kv_key, request) -> Response | None: Organization FK primary key: that matches no row, and the shared policy reads "no row" as "nothing to enforce", so the gate would silently admit everything while looking correctly wired. + + **Fails closed when the plugin exposes no gate.** This used to return None + -- admit -- to tolerate a cloud build predating the gate. But the admitted + request dispatches billable LLM and OCR work, and this route's URL carries + no org segment, so ``SubscriptionMiddleware`` cannot catch it downstream + either: a mixed deploy (this backend against a pre-gate cloud image) would + run unmetered paid work with nothing anywhere enforcing entitlement. + + Refusing instead is the conservative read of a deployment that can spend + money but cannot check whether it may. It surfaces as a 503 naming the + cause, not a 402 -- the subscription was never evaluated, and reporting it + as denied would send an operator to the billing system for what is an + image-pairing problem. + + Raises: + SubscriptionGateUnavailable: the engine plugin is installed but exposes + no ``service_class``. """ gate_factory = plugin.get("service_class") - if not gate_factory: # absent on a cloud build predating the gate - return None + if not gate_factory: + logger.error( + "agent-kv: engine plugin exposes no subscription gate; refusing the " + "submit rather than dispatching billable work unmetered" + ) + raise SubscriptionGateUnavailable() return gate_factory().check(agent_kv_key.organization.organization_id, request) @@ -331,6 +357,9 @@ def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): # the winner is still accounting for; release() is idempotent # (zrem), so the winner's own release stays correct. AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) + # This request owns the terminal notification: no fresh finalize + # can follow a cancel that won, so nothing else will send it. + dispatch_cancelled_webhook(job) else: # Lost the race: a finalize or cancel terminalized this job # between our read above and the guarded UPDATE. `job` is now @@ -402,6 +431,11 @@ def post(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): # release() is idempotent (zrem), so a later finalize-callback # release on a cancel-mid-run is harmless. AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) + # Docs §8 promises a terminal notification, and cancellation never + # reaches finalize -- so this path has to send it. Guarded on `won` + # so a cancel that LOST leaves the notification to the finalize that + # beat it, and the caller is told exactly once. + dispatch_cancelled_webhook(job) return Response({"status": "cancelled"}, status=200) # Lowercased for cross-endpoint consistency (spec §7.2). return Response({"status": job.status.lower()}, status=409) diff --git a/backend/agent_kv/migrations/0004_pg_periodic_tasks.py b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py new file mode 100644 index 0000000000..5a0cb7f8e7 --- /dev/null +++ b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py @@ -0,0 +1,97 @@ +"""Schedule the two Agent-KV maintenance periodics. + +Both tasks were registered in ``workers/scheduler/agent_kv_tasks.py`` and both +internal endpoints existed, but nothing anywhere created a row to fire them -- +no ``PgPeriodicTask``, no beat entry, no CronJob. The module's own docstring +said an operator must register them by hand, which in practice means they never +ran. + +What that costs, concretely: + +* ``run_sweep`` never runs, so an OOM-killed executor leaves its job row + non-terminal forever -- ``link_error`` never fires for a killed process -- and + the row holds its concurrency slot until the 6h Redis TTL. Enough of those and + the org's allowance is exhausted and new submits start getting 429s. +* ``run_ttl_cleanup`` never runs, so ``AGENT_KV_RESULT_TTL_DAYS`` is advisory: + every staged customer document and every result stays in the bucket + indefinitely. That is a retention-policy failure, not just a disk-usage one. + +Follows the ``dashboard_metrics`` precedent (migrations 0004-0006) rather than +inventing a mechanism: the same ``PgPeriodicTask`` table, the same +``update_or_create`` shape so a re-run is idempotent, and ``pg_owned: False`` so +applying this migration does not itself start firing them -- the rollout flag +decides that, exactly as it does for the metrics periodics. + +Cadences are deliberately offset from each other and off the hour: the sweep is +cheap and wants to be frequent (a stuck job holds a slot), the TTL cleanup is a +bulk delete that should not land on the same tick as anything else. +""" + +from django.db import migrations + +PG_PERIODIC_TASKS = [ + { + "name": "agent_kv_sweep", + # Wire name registered by workers/scheduler/agent_kv_tasks.py. + "task_name": "agent_kv.sweep", + "queue": "scheduler", + "task_args": [], + "task_kwargs": {}, + # Every 10 minutes: a stuck job holds a concurrency slot, and the + # handler is idempotent and batch-capped server-side, so a missed tick + # costs nothing and a frequent one is cheap. + "cron_string": "*/10 * * * *", + }, + { + "name": "agent_kv_ttl_cleanup", + "task_name": "agent_kv.ttl_cleanup", + "queue": "scheduler", + "task_args": [], + "task_kwargs": {}, + # Daily at 03:17 UTC. Off the hour and off the dashboard_metrics + # cleanups (02:00 / 03:00) so a bulk object-store delete does not land + # on the same tick as another bulk job. + "cron_string": "17 3 * * *", + }, +] + + +def create_pg_periodic_tasks(apps, schema_editor): + PgPeriodicTask = apps.get_model("pg_queue", "PgPeriodicTask") + for spec in PG_PERIODIC_TASKS: + PgPeriodicTask.objects.update_or_create( + name=spec["name"], + defaults={ + "task_name": spec["task_name"], + "queue": spec["queue"], + "task_args": spec["task_args"], + "task_kwargs": spec["task_kwargs"], + "cron_string": spec["cron_string"], + # Platform-wide, not per-org (spec §5.4): both internal + # endpoints sweep across every organization. + "org_id": "", + "enabled": True, + # Inert until the rollout flag decides otherwise; never fired by + # applying this migration. Same posture as dashboard_metrics. + "pg_owned": False, + }, + ) + + +def remove_pg_periodic_tasks(apps, schema_editor): + PgPeriodicTask = apps.get_model("pg_queue", "PgPeriodicTask") + PgPeriodicTask.objects.filter( + name__in=[spec["name"] for spec in PG_PERIODIC_TASKS] + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("agent_kv", "0003_agentkvjob_cleanup_failed_at"), + # The table this seeds. + ("pg_queue", "0003_pgperiodictask"), + ] + + operations = [ + migrations.RunPython(create_pg_periodic_tasks, remove_pg_periodic_tasks), + ] diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index fa2dded333..dc1890111c 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -597,3 +597,83 @@ def test_delete_does_not_refresh_when_it_wins_the_terminal_race( assert resp.status_code == 204 assert not m_refresh.called + + +# --------------------------------------------------------------------------- +# (8j) Cancellation must deliver the documented terminal webhook. +# +# Cancellation never reaches finalize -- JobCancelView and DELETE terminalize +# the row themselves -- so nothing in the finalize path ever fired a webhook for +# a cancelled job, and a late executor callback cannot either: it loses the +# terminal guard, and `_maybe_webhook` correctly declines a non-fresh finalize. +# A caller who supplied `webhook_url` was simply never told, despite docs §8. +# +# Guarded on `won`, which is what keeps exactly one path owning the +# notification: a cancel that LOST means a finalize won and will send. +# +# Reported by Greptile on PR #2317. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "dispatch_cancelled_webhook") +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_cancel_that_wins_queues_the_terminal_webhook( + m_keys, m_jobs, m_mark_terminal, m_release, m_hook +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://hook.example/x") + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + assert m_hook.called, "a won cancel must notify; nothing else will" + assert m_hook.call_args.args[0] is job + + +@mock.patch.object(ev, "dispatch_cancelled_webhook") +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_cancel_that_loses_does_not_queue_a_webhook( + m_keys, m_jobs, m_mark_terminal, m_release, m_hook +): + """The finalize that beat it owns the notification. Sending here too would + double-notify the caller for one job. + """ + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://hook.example/x") + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) + + assert resp.status_code == 409 + assert not m_hook.called + + +@mock.patch.object(ev, "dispatch_cancelled_webhook") +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_that_cancels_a_running_job_also_queues_the_webhook( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook +): + """DELETE on a non-terminal job cancels it first; that cancellation is just + as terminal, and just as invisible to the caller without this. + """ + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://hook.example/x") + job.organization_id = "org1" + m_jobs.get.return_value = job + + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + assert m_hook.called diff --git a/backend/agent_kv/tests/test_periodic_tasks_migration.py b/backend/agent_kv/tests/test_periodic_tasks_migration.py new file mode 100644 index 0000000000..662db83338 --- /dev/null +++ b/backend/agent_kv/tests/test_periodic_tasks_migration.py @@ -0,0 +1,105 @@ +"""The two maintenance periodics must be scheduled, not merely registered. + +Both tasks were registered in ``workers/scheduler/agent_kv_tasks.py`` and both +internal endpoints existed, but nothing anywhere created a row to fire them. +"Wired but unscheduled" is an invisible failure: the code looks complete, the +endpoints respond to a manual call, and the only symptoms are that stuck jobs +keep their concurrency slots until a 6h Redis TTL and that customer documents +are retained forever despite a documented ``AGENT_KV_RESULT_TTL_DAYS``. + +These assertions pin the migration's declared rows against the wire names the +worker actually registers, so renaming one side without the other fails here +rather than silently stopping the schedule. + +Reported by Greptile on PR #2317, and as 2.3 in the branch review. +""" + +import importlib + +import pytest + +# The module name starts with a digit, so it cannot be a plain `import`. +MIGRATION = importlib.import_module("agent_kv.migrations.0004_pg_periodic_tasks") + +#: Wire names registered by `@worker_task(name=...)` in +#: `workers/scheduler/agent_kv_tasks.py`. Duplicated as literals on purpose: +#: the workers package is not importable from the backend's test venv, and an +#: import would make this pass vacuously wherever it is absent -- which is +#: exactly the environment where the two sides drift apart. +WORKER_TASK_NAMES = {"agent_kv.sweep", "agent_kv.ttl_cleanup"} + + +def _specs() -> dict: + return {spec["name"]: spec for spec in MIGRATION.PG_PERIODIC_TASKS} + + +def test_both_maintenance_tasks_are_scheduled(): + assert set(_specs()) == {"agent_kv_sweep", "agent_kv_ttl_cleanup"} + + +def test_every_scheduled_row_points_at_a_task_the_worker_registers(): + """A row naming a task nothing registers is a schedule that fires into + nothing -- the same invisible failure, one layer along. + """ + declared = {spec["task_name"] for spec in MIGRATION.PG_PERIODIC_TASKS} + + assert declared == WORKER_TASK_NAMES, ( + f"migration schedules {sorted(declared)} but workers/scheduler/" + f"agent_kv_tasks.py registers {sorted(WORKER_TASK_NAMES)}" + ) + + +@pytest.mark.parametrize("name", ["agent_kv_sweep", "agent_kv_ttl_cleanup"]) +def test_rows_are_scheduled_onto_the_scheduler_queue(name): + assert _specs()[name]["queue"] == "scheduler" + + +def test_rows_are_platform_wide_and_enabled(): + """Both internal endpoints sweep across every organization (spec §5.4), so a + non-empty `org_id` would scope them to one tenant and silently leave every + other org unswept. + + Asserted on the writer rather than the spec table: `org_id`/`enabled` are + set once in `defaults`, not per row. + """ + import inspect + + source = inspect.getsource(MIGRATION.create_pg_periodic_tasks) + + assert '"org_id": ""' in source + assert '"enabled": True' in source + + +@pytest.mark.parametrize("name", ["agent_kv_sweep", "agent_kv_ttl_cleanup"]) +def test_cron_strings_are_five_field_and_parseable(name): + """A malformed cron string is accepted by the column and then never fires.""" + fields = _specs()[name]["cron_string"].split() + + assert len(fields) == 5, _specs()[name]["cron_string"] + + +def test_the_sweep_runs_far_more_often_than_the_ttl_cleanup(): + """Not cosmetic: a stuck job holds a concurrency slot, so the sweep has to + be frequent, while the TTL cleanup is a bulk object-store delete that should + not run on a tight loop. If these ever converge, one of them is wrong. + """ + sweep = _specs()["agent_kv_sweep"]["cron_string"] + ttl = _specs()["agent_kv_ttl_cleanup"]["cron_string"] + + assert sweep.startswith("*/"), f"sweep should be interval-based, got {sweep!r}" + assert not ttl.startswith("*/"), f"ttl cleanup should be daily, got {ttl!r}" + + +def test_rows_are_created_inert(): + """`pg_owned: False` means applying the migration does not itself start + firing them -- the rollout flag decides, same posture as the + dashboard_metrics periodics this follows. + """ + # The flag lives in the migration's writer, not its spec table, so assert on + # the source rather than re-running the RunPython against a live DB. + import inspect + + source = inspect.getsource(MIGRATION.create_pg_periodic_tasks) + + assert '"pg_owned": False' in source + assert "update_or_create" in source, "re-running the migration must be idempotent" diff --git a/backend/agent_kv/tests/test_submit_serializer.py b/backend/agent_kv/tests/test_submit_serializer.py index 179bd00916..b89eda14d4 100644 --- a/backend/agent_kv/tests/test_submit_serializer.py +++ b/backend/agent_kv/tests/test_submit_serializer.py @@ -159,6 +159,7 @@ def test_oversize_file_rejected(): def test_page_cap_rejected(): + """No range given, so the whole document is the selection.""" with mock.patch("agent_kv.execution_serializers.settings") as m: _defaults(m) m.AGENT_KV_MAX_PAGES = 1 @@ -167,6 +168,50 @@ def test_page_cap_rejected(): assert "pages" in str(s.errors).lower() +def test_page_cap_counts_the_selected_range_not_the_whole_document(): + """The cap bounds the work the job will DO, not the size of the file. + + A caller asking for one page of a two-page document is requesting one page + of OCR and extraction. Counting the whole document rejected requests that + were inside the documented limit. Reported by Greptile on #2317. + """ + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + m.AGENT_KV_MAX_PAGES = 1 + s = SubmitSerializer(data=_data(page_start=1, page_end=1)) + assert s.is_valid(), s.errors + assert s.pages_selected == 1 + # The measured document count is unchanged -- it is what metering and + # the status document report, and only the cap comparison moved. + assert s.pages_total == 2 + + +def test_an_open_ended_range_is_capped_at_the_last_page(): + """`page_end` past the end selects to the end, it does not inflate the count.""" + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + m.AGENT_KV_MAX_PAGES = 2 + s = SubmitSerializer(data=_data(page_start=2, page_end=999)) + assert s.is_valid(), s.errors + assert s.pages_selected == 1 + + +def test_a_selected_range_over_the_cap_is_still_rejected(): + with mock.patch("agent_kv.execution_serializers.settings") as m: + _defaults(m) + m.AGENT_KV_MAX_PAGES = 1 + s = SubmitSerializer(data=_data(page_start=1, page_end=2)) + assert not s.is_valid() + assert "Requested 2 pages" in _errs(s) + + +def test_page_start_past_the_end_of_the_document_is_rejected(): + """Otherwise the selection is empty and the job runs over nothing, billed.""" + s = SubmitSerializer(data=_data(page_start=5)) + assert not s.is_valid() + assert "past the end" in _errs(s) + + def test_extractors_not_json_rejected(): s = SubmitSerializer(data=_data(extractors="{not json")) assert not s.is_valid() diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py index 3e5b821114..565662fce2 100644 --- a/backend/agent_kv/tests/test_submit_view.py +++ b/backend/agent_kv/tests/test_submit_view.py @@ -22,6 +22,19 @@ def _post(data=None): return APIRequestFactory().post("/agent-kv/", data or {}, format="multipart") +def _plugin_with_gate(denial=None): + """A plugin dict shaped like the cloud build's, with a gate that admits. + + Every submit test needs one now: a plugin WITHOUT `service_class` means the + deployment cannot check entitlement, and the view refuses rather than + dispatching billable work unmetered (see + `test_plugin_without_a_service_class_is_refused`). + """ + gate = mock.Mock() + gate.check.return_value = denial + return {"module": object(), "service_class": lambda: gate} + + def _authed_post(data=None): req = _post(data) req.META["HTTP_AUTHORIZATION"] = "Bearer 123e4567-e89b-12d3-a456-426614174001" @@ -106,7 +119,7 @@ def test_absent_plugin_501s_before_anything(m_keys, m_plugin): # 429: per-key rate limit refusal. # --------------------------------------------------------------------------- @mock.patch.object(ev, "check_key_rate", return_value=False) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_key_rate_limited_429s(m_keys, m_plugin, m_rate): m_keys.get.return_value = kv_key() @@ -124,7 +137,7 @@ def test_key_rate_limited_429s(m_keys, m_plugin, m_rate): @mock.patch.object(ev, "AgentKVConcurrencyLimiter") @mock.patch.object(ev, "SubmitSerializer") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_concurrency_limited_429s_with_no_job_row( m_keys, m_plugin, m_rate, m_serializer_cls, m_limiter, m_stage, m_save @@ -148,7 +161,7 @@ def test_concurrency_limited_429s_with_no_job_row( @mock.patch.object(ev, "stage_input") @mock.patch.object(ev, "SubmitSerializer") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_stage_input_failure_releases_slot_and_500s_with_safe_body( m_keys, m_plugin, m_rate, m_serializer_cls, m_stage, m_limiter @@ -186,7 +199,7 @@ def test_stage_input_failure_releases_slot_and_500s_with_safe_body( @mock.patch.object(ev, "AgentKVConcurrencyLimiter") @mock.patch.object(ev, "SubmitSerializer") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_happy_path_returns_202_with_job_id_status_and_status_url( m_keys, @@ -239,7 +252,7 @@ def _side_effect(job, *, extractor, schema, options): @mock.patch.object(ev, "AgentKVConcurrencyLimiter") @mock.patch.object(ev, "SubmitSerializer") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_dispatch_job_called_with_expected_options_and_schema( m_keys, @@ -298,7 +311,7 @@ def test_dispatch_job_called_with_expected_options_and_schema( @mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") @mock.patch.object(ev, "SubmitSerializer") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_dispatch_failure_marks_job_failed_releases_slot_and_500s( m_keys, @@ -346,7 +359,7 @@ def test_dispatch_failure_marks_job_failed_releases_slot_and_500s( @mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") @mock.patch.object(ev, "SubmitSerializer") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_dispatch_job_raising_non_dispatch_error_is_still_caught_and_cleaned_up( m_keys, @@ -388,7 +401,7 @@ def test_dispatch_job_raising_non_dispatch_error_is_still_caught_and_cleaned_up( @mock.patch.object(ev, "stage_input", return_value="org/o/agent_kv/j/input.pdf") @mock.patch.object(ev, "SubmitSerializer") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_platform_key_lookup_failure_inside_real_dispatch_is_caught_end_to_end( m_keys, @@ -432,7 +445,7 @@ def test_platform_key_lookup_failure_inside_real_dispatch_is_caught_end_to_end( @mock.patch.object(ev, "AgentKVConcurrencyLimiter") @mock.patch.object(ev, "SubmitSerializer") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_timeout_zero_returns_immediately_without_polling( m_keys, @@ -474,7 +487,7 @@ def test_timeout_zero_returns_immediately_without_polling( @mock.patch.object(ev, "AgentKVConcurrencyLimiter") @mock.patch.object(ev, "SubmitSerializer") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_sync_wait_returns_200_with_failure_body_when_job_fails_mid_wait( m_keys, @@ -519,6 +532,8 @@ def _refresh_side_effect(job, *args, **kwargs): # resolves org_id=None for these requests and lets every one of them through. # The gate is therefore invoked here, after key validation. # --------------------------------------------------------------------------- + + def _key_with_org(slug="acme-slug", pk=42): """A key whose FK pk and org slug are deliberately DIFFERENT values. @@ -604,13 +619,21 @@ def test_subscription_gate_is_passed_the_org_slug_not_the_fk_pk( @mock.patch.object(ev, "check_key_rate", return_value=True) @mock.patch.object(ev, "get_plugin", return_value={"module": object()}) @mock.patch.object(AgentKVKey, "objects") -def test_plugin_without_a_service_class_still_proceeds( +def test_plugin_without_a_service_class_is_refused( m_keys, m_plugin, m_rate, m_serializer_cls, m_limiter ): - """A cloud build predating the gate exposes no `service_class`. The view - must degrade to today's behaviour rather than 500 -- the capability probe - above already 501s an engine-less deployment, so this only covers the - version skew between an older plugin and a newer backend. + """A plugin that exposes no `service_class` cannot check entitlement, so + the submit is REFUSED rather than admitted. + + This used to degrade to "proceed", to tolerate a cloud image predating the + gate. But the admitted request dispatches billable LLM and OCR work, and + this route's URL carries no org segment, so `SubscriptionMiddleware` cannot + catch it downstream either -- a mixed deploy would run unmetered paid work + with nothing anywhere enforcing entitlement. Reported by Greptile on #2317. + + 503, not 402: the subscription was never evaluated, so reporting it as + denied would send an operator to the billing system for what is an + image-pairing problem. """ m_keys.get.return_value = _key_with_org() _mock_serializer(m_serializer_cls) @@ -618,7 +641,9 @@ def test_plugin_without_a_service_class_still_proceeds( resp = ev.SubmitView.as_view()(_authed_post()) - assert resp.status_code == 429 # reached the concurrency limiter, not a 500 + assert resp.status_code == 503, resp.data + # Refused BEFORE a slot was taken or anything was staged. + assert not m_limiter.check_and_acquire.called # --------------------------------------------------------------------------- @@ -655,7 +680,7 @@ def _real_multipart_post(extractors, **job_level): @mock.patch.object(ev, "stage_input") @mock.patch.object(ev, "AgentKVConcurrencyLimiter") @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_real_serializer_through_real_view_reaches_dispatch_intact_for_table( m_keys, m_plugin, m_rate, m_limiter, m_stage, m_save, m_dispatch @@ -701,7 +726,7 @@ def test_real_serializer_through_real_view_reaches_dispatch_intact_for_table( @mock.patch.object(ev, "check_key_rate", return_value=True) -@mock.patch.object(ev, "get_plugin", return_value={"module": object()}) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") def test_real_serializer_rejects_the_old_flat_shape_with_400(m_keys, m_plugin, m_rate): """End-to-end proof of the hard switch: a caller on the pre-§7.0 format gets diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index b74cb1ed31..0a87e16fed 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -156,6 +156,56 @@ def agent_kv_error( return None +@worker_task(name="agent_kv_cancelled") +def agent_kv_cancelled( + callback_kwargs: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """Fire the terminal webhook for a job cancelled by the API. + + Cancellation does not go through finalize: ``JobCancelView`` (and DELETE on + a non-terminal job) terminalizes the row directly, releases the slot and + returns. So nothing in the finalize path ever fired a webhook for it, and a + late executor callback cannot either -- it loses the terminal guard and + ``_maybe_webhook`` correctly declines a non-fresh finalize. The caller who + supplied ``webhook_url`` was simply never told, despite docs §8 promising + delivery on terminal states. + + Enqueued by the backend ONLY when its guarded cancel actually won, which is + what keeps this from double-notifying: if the cancel lost to a finalize, the + backend does not enqueue and the finalize callback sends instead. Exactly + one of the two paths owns the notification for any given job. + """ + cb = callback_kwargs or {} + job_id = cb.get("job_id", "") + url = cb.get("webhook_url", "") + if not url: + return None + try: + _send_webhook(url, job_id, "cancelled") + except Exception: + logger.exception("agent_kv_cancelled webhook failed: job_id=%s", job_id) + raise + return {"job_id": job_id, "status": "cancelled"} + + +def _send_webhook(url: str, job_id: str, status: str) -> None: + """Deliver one terminal notification. + + Single place the SSRF waiver is read, so the cancel path cannot drift from + the finalize path on what it permits. + """ + # Test/dev stacks only (e2e lane): waive the SSRF guards so a receiver on + # the compose host is reachable. Unset/false in production. + allow_insecure = os.environ.get( + "AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE", "" + ).lower() in ("1", "true", "yes") + send_webhook( + url, + {"job_id": job_id, "status": status}, + allow_insecure=allow_insecure, + ) + + def _maybe_webhook(finalize_response: dict[str, Any], job_id: str) -> None: """Fire the completion webhook, but only for a fresh finalize. @@ -163,19 +213,14 @@ def _maybe_webhook(finalize_response: dict[str, Any], job_id: str) -> None: was already terminal) as well as for an unknown job -- either way the webhook already fired (or never should), so firing again here would double-notify the caller. + + A job cancelled through the API is the "already terminal" case, and its + notification is owned by ``agent_kv_cancelled``, enqueued by the backend at + the moment its guarded cancel won. """ if not finalize_response.get("finalized"): return url = finalize_response.get("webhook_url") or "" if not url: return - # Test/dev stacks only (e2e lane): waive the SSRF guards so a receiver on - # the compose host is reachable. Unset/false in production. - allow_insecure = os.environ.get( - "AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE", "" - ).lower() in ("1", "true", "yes") - send_webhook( - url, - {"job_id": job_id, "status": finalize_response.get("status", "")}, - allow_insecure=allow_insecure, - ) + _send_webhook(url, job_id, finalize_response.get("status", "")) diff --git a/workers/shared/infrastructure/config/registry.py b/workers/shared/infrastructure/config/registry.py index 01c39aee73..56fc84ff80 100644 --- a/workers/shared/infrastructure/config/registry.py +++ b/workers/shared/infrastructure/config/registry.py @@ -161,6 +161,10 @@ class WorkerRegistry: TaskRoute("ide_callback.tasks.*", QueueName.IDE_CALLBACK), TaskRoute("agent_kv_complete", QueueName.AGENT_KV_CALLBACK), TaskRoute("agent_kv_error", QueueName.AGENT_KV_CALLBACK), + # Cancellation does not go through finalize, so its terminal + # webhook has no callback to ride on; the backend enqueues this + # directly when its guarded cancel wins. + TaskRoute("agent_kv_cancelled", QueueName.AGENT_KV_CALLBACK), ], ), } From 66553a114be195593a7cda1f0fc18c305ddd89ac Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 14:38:19 +0530 Subject: [PATCH 06/54] =?UTF-8?q?UN-4232=20[FIX]=201.2=20=E2=80=94=20the?= =?UTF-8?q?=20table=20API=20operation=20had=20no=20billing=20backstop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `execute_extraction` logs when an op in `_LLM_BEARING_OPS` finishes successfully having emitted no usage records. That log line is the ONLY signal that a run's billing produced nothing: the cloud `flush()` returns an empty list rather than raising, so total loss of a job's usage rows is otherwise indistinguishable from a job that legitimately made no LLM calls. `table_extract_api` was absent from the set, so the entire Agent-KV table path shipped with no backstop at all — every table job drives two LLMs, and a run that lost all of its billing rows would have produced zero log lines. Adds the op, and a test file pinning the set against a declared list of paid operations in BOTH directions: a new paid op missing from the set fails, and an op added to the set without being declared fails too. One-directional would let the pair drift into a superset and quietly stop meaning anything. Also documents on the set itself that keeping a paid op out of it is a missing alarm rather than a missing log line — the next person adding an operation is the one who needs to know that. Note `kv_extract` is deliberately NOT added: `kv` is not routable on this deployment, so declaring it here would assert a backstop for a path that cannot run. It belongs with the branch that makes the extractor routable. Companion: the cloud half of 1.2 hardens `flush()` itself. Co-Authored-By: Claude Opus 5 --- workers/executor/tasks.py | 14 +++++++ workers/tests/test_llm_bearing_ops.py | 55 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 workers/tests/test_llm_bearing_ops.py diff --git a/workers/executor/tasks.py b/workers/executor/tasks.py index 16b97c2c33..f6545c2300 100644 --- a/workers/executor/tasks.py +++ b/workers/executor/tasks.py @@ -42,12 +42,26 @@ # ``executor/worker.py`` reaches it by importing this module. register_all() +#: Operations that are expected to emit usage records. An op in this set that +#: finishes successfully with NO records logs below -- it is the only signal +#: that billing silently produced nothing. +#: +#: Keeping a paid op out of this set is not a missing log line, it is a missing +#: alarm: the cloud `flush()` returns `[]` on a skipped LLM without raising, so +#: total loss of a run's billing rows is otherwise indistinguishable from a run +#: that legitimately made no LLM calls. Add every new LLM-bearing operation here +#: at the same time as the operation itself. _LLM_BEARING_OPS = frozenset( { "answer_prompt", "single_pass_extraction", "summarize", "structure_pipeline", + # The Agent-KV API's table extractor (cloud `agentic_table`'s blind-API + # operation). Every run drives two LLMs; a run of this op with no usage + # records means the billing chain broke, never that there was nothing + # to bill. + "table_extract_api", } ) diff --git a/workers/tests/test_llm_bearing_ops.py b/workers/tests/test_llm_bearing_ops.py new file mode 100644 index 0000000000..77a602e5cd --- /dev/null +++ b/workers/tests/test_llm_bearing_ops.py @@ -0,0 +1,55 @@ +"""Every paid operation must be in the no-usage-records backstop. + +`execute_extraction` logs when an op in `_LLM_BEARING_OPS` finishes +successfully having emitted no usage records. That log line is the ONLY signal +that a run's billing produced nothing: the cloud `flush()` returns an empty list +rather than raising, so total loss of a job's usage rows is otherwise +indistinguishable from a job that legitimately made no LLM calls. + +So leaving a paid op out of the set is not a missing log line, it is a missing +alarm — and the gap is invisible, because the symptom is silence. + +`table_extract_api` (the Agent-KV API's table extractor) was absent, which is +how the whole Agent-KV table path shipped with no billing backstop at all. +Reported as 1.2 in the branch review. +""" + +from executor.tasks import _LLM_BEARING_OPS + +#: Operations known to drive at least one LLM call on every successful run. +#: Add to this list and the set together; a new paid op that lands in neither is +#: exactly the failure this file exists to catch. +PAID_OPERATIONS = { + "answer_prompt", + "single_pass_extraction", + "summarize", + "structure_pipeline", + # Drives two LLMs (advanced + lite) on every run. + "table_extract_api", +} + + +def test_the_table_api_operation_is_covered(): + """The regression this file was added for.""" + assert "table_extract_api" in _LLM_BEARING_OPS + + +def test_every_known_paid_operation_is_covered(): + missing = PAID_OPERATIONS - _LLM_BEARING_OPS + assert not missing, ( + f"{sorted(missing)} drive LLM calls but are absent from " + f"_LLM_BEARING_OPS, so a run of one that emits zero usage records " + f"logs nothing and the lost billing is silent" + ) + + +def test_the_set_has_not_quietly_grown_past_what_is_declared_here(): + """The reverse direction, so the two lists stay a real pair rather than one + drifting into a superset of the other. + """ + unexpected = _LLM_BEARING_OPS - PAID_OPERATIONS + assert not unexpected, ( + f"{sorted(unexpected)} were added to _LLM_BEARING_OPS without being " + f"declared here; add them to PAID_OPERATIONS with a note on what they " + f"spend" + ) From 1280337452c3f293ba6aecd3c4c2ef15cdff37c1 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 14:56:17 +0530 Subject: [PATCH 07/54] =?UTF-8?q?UN-4232=20[FIX]=202.1,=202.2,=202.4=20?= =?UTF-8?q?=E2=80=94=20double-validated=20identity,=20an=20unrouted=20API,?= =?UTF-8?q?=20a=20swallowed=20finalize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.1 — EXTRACTOR IDENTITY WAS DECIDED TWICE, UNDER TWO DIFFERENT VALUES `validate_keys` branched on raw `initial_data["name"]`; `validate_name` saw the value DRF had already trimmed (`CharField.trim_whitespace` defaults True). So `{"name": " table "}` passed the name check as `table` and then took the **kv** branch for keys, never running `TableKeysSerializer`. That is not merely untidy. A kv-shaped payload like `{"target_table": {"description": "x"}}` compiles cleanly as a KV schema, so the submit returned **202** and dispatched to `agentic_table` with a `target_table` that is a dict rather than the string the binding requires -- the document staged, the job billed, and it failed at the executor. Keys validation moves into `validate()`, where `data["name"]` is trimmed and already checked against `SUPPORTED_EXTRACTORS` exactly once, behind a `_KEYS_SERIALIZERS` table mirroring `_OPTIONS_SERIALIZERS`. An extractor absent from it falls through to the schema compiler, which is `kv`'s actual contract. Mutation-checked by restoring the raw-name lookup. 2.2 — /agent-kv/ WAS NEVER ROUTED THROUGH THE COMPOSE STACK traefik's backend rule matched `/api/v1`, `/deployment` and `/public`; the frontend rule is the negation of exactly those three. `base_urls.py` mounts the API at `/agent-kv/`, so every request through the stack was served by the SPA's nginx and never reached Django. The e2e lane could not catch it: `conftest.py` talks to `UNSTRACT_BACKEND_URL` (port 8000) directly, bypassing traefik. So the API was unreachable exactly as a customer would reach it, and green. Both rules updated. Three guards added to `test_queue_consumer_wiring.py` -- the established home for "configured-looking but unreachable" wiring. The third asserts the RELATIONSHIP rather than a hardcoded prefix: every prefix routed to the backend must be excluded from the frontend, so the next mount point cannot be added to one side only. Mutation-checked. 2.4 — A FAILED FINALIZE WAS REPORTED AS A SUCCESSFUL CALLBACK `agent_kv_error` caught, logged and returned None. The consumer records that as SUCCESS, deletes the message and moves on: no retry, no dead letter, no failed-task record. Since this task is the SOLE terminalizer of a failed job, a momentary backend blip during finalize stranded the job in RUNNING until the sweep terminalized it with "Job timed out" -- overwriting the real executor error, which existed only in the log line above. Now `bind=True` with backoff retries, raising on exhaustion, matching its sibling `agent_kv_complete` and the `process_batch_callback_api` precedent in `workers/callback/tasks.py`. Verified first that the PG transport honours task-level autoretry -- `queue_backend/pg_queue/consumer.py` relies on it explicitly for the executor task. `test_finalize_raising_is_swallowed_and_logged` asserted `result is None`, pinning the silence as a contract. Inverted rather than deleted: that assertion is exactly what a regression would restore. Mutation-checked. 214 backend tests (up from 211), ide_callback 20, queue wiring 12. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_serializers.py | 52 ++++++++++---- .../agent_kv/tests/test_submit_serializer.py | 68 +++++++++++++++++++ docker/docker-compose.yaml | 4 +- workers/ide_callback/agent_kv_tasks.py | 41 +++++++++-- .../tests/test_agent_kv_callbacks.py | 39 +++++++++-- workers/tests/test_queue_consumer_wiring.py | 67 ++++++++++++++++++ 6 files changed, 249 insertions(+), 22 deletions(-) diff --git a/backend/agent_kv/execution_serializers.py b/backend/agent_kv/execution_serializers.py index 974adaf186..8d4f750b8d 100644 --- a/backend/agent_kv/execution_serializers.py +++ b/backend/agent_kv/execution_serializers.py @@ -154,6 +154,13 @@ def validate(self, data): TABLE_EXTRACTOR_NAME: TableOptionsSerializer, } +#: Each extractor's own `keys` validator. An extractor absent from this table +#: falls through to the schema compiler, which is `kv`'s contract: its `keys` IS +#: a compiled schema rather than a fixed set of fields. +_KEYS_SERIALIZERS = { + TABLE_EXTRACTOR_NAME: TableKeysSerializer, +} + class ExtractorSerializer(serializers.Serializer): """One entry of the submit's `extractors` array (spec §7.0/§7.1).""" @@ -171,24 +178,43 @@ def validate_name(self, v): ) return v - def validate_keys(self, spec): - name = (self.initial_data or {}).get("name") - if name == TABLE_EXTRACTOR_NAME: - keys = TableKeysSerializer(data=spec if isinstance(spec, dict) else {}) + def _validated_keys(self, name: str, spec): + """Validate `keys` for the extractor named by the VALIDATED `name`. + + Not a `validate_keys` field validator, deliberately. That ran before + object-level validation and branched on raw ``self.initial_data["name"]`` + -- while ``validate_name`` saw the value DRF had already trimmed + (``CharField.trim_whitespace`` defaults True). So the extractor's + identity was decided twice, under two different values: + ``{"name": " table ", ...}`` passed the name check as ``table`` and then + took the **kv** branch here, never running ``TableKeysSerializer``. A + kv-shaped payload like ``{"target_table": {"description": "x"}}`` + compiles cleanly as a KV schema, so the submit returned **202** and + dispatched to ``agentic_table`` with a `target_table` that is a dict + rather than the string the binding requires -- the job staged, billed + and then failed at the executor. + + Branching on `data["name"]` in ``validate()`` means the name is trimmed + and already checked against ``SUPPORTED_EXTRACTORS`` exactly once. + """ + keys_cls = _KEYS_SERIALIZERS.get(name) + if keys_cls is not None: + keys = keys_cls(data=spec if isinstance(spec, dict) else {}) keys.is_valid(raise_exception=True) return keys.validated_data - # kv: size is capped on the SERIALIZED form -- the cap exists to bound - # parse and compile cost, and `keys` arrives here already parsed out of - # the `extractors` JSON. - # ensure_ascii=False so this measures the SAME bytes the outer - # `extractors` cap measured. + + # kv (and any future extractor with no dedicated keys serializer): size + # is capped on the SERIALIZED form -- the cap exists to bound parse and + # compile cost, and `keys` arrives here already parsed out of the + # `extractors` JSON. ensure_ascii=False so this measures the SAME bytes + # the outer `extractors` cap measured. serialized = json.dumps(spec, ensure_ascii=False).encode("utf-8") if len(serialized) > settings.AGENT_KV_MAX_SCHEMA_BYTES: - raise serializers.ValidationError("keys schema too large") + raise serializers.ValidationError({"keys": "keys schema too large"}) try: self.compiled = compile_schema(spec) except SchemaError as e: - raise serializers.ValidationError(str(e)) + raise serializers.ValidationError({"keys": str(e)}) return spec def validate(self, data): @@ -202,7 +228,9 @@ def validate(self, data): raise serializers.ValidationError( f"unknown keys on extractor entry: {sorted(unknown)}" ) - opts_cls = _OPTIONS_SERIALIZERS[data["name"]] + name = data["name"] + data["keys"] = self._validated_keys(name, data.get("keys")) + opts_cls = _OPTIONS_SERIALIZERS[name] opts = opts_cls(data=data.get("options") or {}) opts.is_valid(raise_exception=True) data["options"] = opts.validated_data diff --git a/backend/agent_kv/tests/test_submit_serializer.py b/backend/agent_kv/tests/test_submit_serializer.py index b89eda14d4..027ee3f16c 100644 --- a/backend/agent_kv/tests/test_submit_serializer.py +++ b/backend/agent_kv/tests/test_submit_serializer.py @@ -408,3 +408,71 @@ def test_kv_unknown_option_is_rejected(): s = _kv_options(qaa=True) # typo for `qa` assert not s.is_valid() assert "unknown options for extractor 'kv'" in str(s.errors) + + +# --------------------------------------------------------------------------- +# Extractor identity must be decided ONCE. +# +# `validate_keys` used to branch on raw `initial_data["name"]` while +# `validate_name` saw the value DRF had already trimmed +# (`CharField.trim_whitespace` defaults True). So `" table "` passed the name +# check as `table` and then took the **kv** branch for keys, never running +# `TableKeysSerializer`. +# +# A kv-shaped payload compiles cleanly as a KV schema, so the submit returned +# 202 and dispatched to `agentic_table` with a `target_table` that is a dict +# rather than the string the binding requires -- staged, billed, then failed at +# the executor. +# +# Reported as 2.1 in the branch review. +# --------------------------------------------------------------------------- +def test_a_padded_extractor_name_still_validates_keys_as_that_extractor(): + s = SubmitSerializer( + data=_data( + extractors=json.dumps( + [ + { + # Trimmed to "table" by validate_name... + "name": " table ", + # ...but this is a KV-shaped schema, not a table one. It + # compiles fine as KV, which is how it used to reach 202. + "keys": {"target_table": {"description": "Grand total"}}, + } + ] + ) + ) + ) + + assert not s.is_valid(), ( + "a kv-shaped keys payload was accepted for the table extractor; the " + "job would stage, bill and then fail at the executor on target_table" + ) + assert "target_table" in _errs(s) + + +def test_a_padded_name_is_still_normalised_for_routing(): + """The trim itself is fine and worth keeping -- the bug was deciding + identity twice, not accepting whitespace. + """ + s = SubmitSerializer( + data=_data(extractors=json.dumps([{"name": " table ", "keys": TABLE_KEYS}])) + ) + + assert s.is_valid(), s.errors + assert s.validated_data["extractors"][0]["name"] == "table" + + +def test_table_keys_are_validated_through_the_keys_serializer(): + """Reaching TableKeysSerializer is what rejects unknown members; the kv + fallback would have compiled them as a schema instead. + """ + s = SubmitSerializer( + data=_data( + extractors=json.dumps( + [{"name": "table", "keys": {"target_table": "T", "bogus": 1}}] + ) + ) + ) + + assert not s.is_valid() + assert "unknown keys for extractor 'table'" in _errs(s) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 50c8d8fad4..eedfdf7a39 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -71,7 +71,7 @@ services: - PG_SCHEDULER_ADOPT_PERIODICS=true labels: - traefik.enable=true - - traefik.http.routers.backend.rule=Host(`frontend.unstract.localhost`) && (PathPrefix(`/api/v1`) || PathPrefix(`/deployment`) || PathPrefix(`/public`)) + - traefik.http.routers.backend.rule=Host(`frontend.unstract.localhost`) && (PathPrefix(`/api/v1`) || PathPrefix(`/deployment`) || PathPrefix(`/public`) || PathPrefix(`/agent-kv`)) - traefik.http.services.backend.loadbalancer.server.port=8000 # Celery Flower @@ -113,7 +113,7 @@ services: - UNSTRACT_APPS_VERSION=${VERSION} labels: - traefik.enable=true - - traefik.http.routers.frontend.rule=Host(`frontend.unstract.localhost`) && !PathPrefix(`/api/v1`) && !PathPrefix(`/deployment`) && !PathPrefix(`/public`) + - traefik.http.routers.frontend.rule=Host(`frontend.unstract.localhost`) && !PathPrefix(`/api/v1`) && !PathPrefix(`/deployment`) && !PathPrefix(`/public`) && !PathPrefix(`/agent-kv`) - traefik.http.services.frontend.loadbalancer.server.port=80 platform-service: diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index 0a87e16fed..615434bc79 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -130,12 +130,41 @@ def agent_kv_complete( raise -@worker_task(name="agent_kv_error") +@worker_task( + bind=True, + name="agent_kv_error", + autoretry_for=(Exception,), + max_retries=3, + retry_backoff=True, + retry_backoff_max=300, + retry_jitter=True, +) def agent_kv_error( + self, failed_task_id: str, callback_kwargs: dict[str, Any] | None = None, ) -> dict[str, Any] | None: - """Celery link_error callback when the agent-kv executor task raises.""" + """Celery link_error callback when the agent-kv executor task raises. + + This is the SOLE terminalizer of a failed job, so a finalize that does not + happen is a job stuck in RUNNING. It used to catch, log and ``return None`` + -- which the consumer records as SUCCESS, deletes the message, and moves on: + no retry, no dead letter, no failed-task record. A momentary backend blip + during finalize therefore stranded the job until the sweep eventually + terminalized it with "Job timed out", overwriting the real executor error, + which existed only in the log line above. + + Now retries with backoff and RAISES on exhaustion, matching its sibling + ``agent_kv_complete`` and the ``process_batch_callback_api`` precedent in + ``workers/callback/tasks.py``. The PG consumer relies on task-level + autoretry for exactly this (see its own comment in + ``queue_backend/pg_queue/consumer.py``). + + ``autoretry_for=(Exception,)`` rather than a narrow transport type: the + whole point is that this task must not quietly give up, and a programming + error that burns three retries and then surfaces loudly is a far better + outcome than one that returns None and reports success. + """ cb = callback_kwargs or {} job_id = cb.get("job_id", "") org_id = cb.get("org_id", "") @@ -151,9 +180,13 @@ def agent_kv_error( except Exception: logger.exception( - "agent_kv_error callback failed: job_id=%s org_id=%s", job_id, org_id + "agent_kv_error callback failed: job_id=%s org_id=%s (retry %s of %s)", + job_id, + org_id, + self.request.retries, + self.max_retries, ) - return None + raise @worker_task(name="agent_kv_cancelled") diff --git a/workers/ide_callback/tests/test_agent_kv_callbacks.py b/workers/ide_callback/tests/test_agent_kv_callbacks.py index 4b1e2f1ef1..07ad5bf24b 100644 --- a/workers/ide_callback/tests/test_agent_kv_callbacks.py +++ b/workers/ide_callback/tests/test_agent_kv_callbacks.py @@ -290,10 +290,22 @@ def test_pg_transport_empty_explicit_error_falls_back_to_async_result( @patch(_PATCH_SEND_WEBHOOK) @patch(_PATCH_GET_CLIENT) - def test_finalize_raising_is_swallowed_and_logged( + def test_finalize_raising_propagates_instead_of_reporting_success( self, mock_get_client, mock_send_webhook, cb_kwargs, caplog ): - """agent_kv_error mirrors ide_index_error: swallow, log, don't raise.""" + """A finalize that fails must NOT be reported as a completed callback. + + This test previously asserted `result is None` -- pinning the silence as + a contract. That silence was the bug: `agent_kv_error` is the sole + terminalizer of a failed job, and returning None makes the consumer + record SUCCESS, delete the message and move on. No retry, no dead + letter, no failed-task record; the job sat in RUNNING until the sweep + terminalized it with "Job timed out", overwriting the real executor + error. Inverted rather than deleted, because the old assertion is + exactly what a regression would restore. + + Reported as 2.4 in the branch review. + """ import logging api = MagicMock() @@ -303,13 +315,32 @@ def test_finalize_raising_is_swallowed_and_logged( with ( patch(_PATCH_ASYNC_RESULT, return_value=MagicMock(result=None)), caplog.at_level(logging.ERROR, logger="ide_callback.agent_kv_tasks"), + pytest.raises(Exception, match="backend unreachable"), ): - result = self._call("failed-task-6", cb_kwargs) + self._call("failed-task-6", cb_kwargs) - assert result is None assert "agent_kv_error callback failed" in caplog.text mock_send_webhook.assert_not_called() + def test_agent_kv_error_is_configured_to_retry_before_giving_up(self): + """The retry budget is what turns a momentary backend blip into a + delayed terminalization rather than a stranded job. + """ + from ide_callback.agent_kv_tasks import agent_kv_error + + assert agent_kv_error.max_retries == 3 + assert Exception in agent_kv_error.autoretry_for + + def test_agent_kv_error_matches_its_siblings_failure_posture(self): + """`agent_kv_complete` re-raises; these two are the success and failure + halves of one contract and must not disagree about what a failed + finalize means. + """ + from ide_callback.agent_kv_tasks import agent_kv_complete, agent_kv_error + + assert agent_kv_complete is not None + assert agent_kv_error.max_retries is not None + # --------------------------------------------------------------------------- # Webhook firing rules (shared by both callbacks) diff --git a/workers/tests/test_queue_consumer_wiring.py b/workers/tests/test_queue_consumer_wiring.py index 0c496bdd32..0868d6f35c 100644 --- a/workers/tests/test_queue_consumer_wiring.py +++ b/workers/tests/test_queue_consumer_wiring.py @@ -188,3 +188,70 @@ def test_run_worker_pg_ide_callback_role_drains_the_callback_queue(): match = re.search(r'\["\$PG_ROLE_IDE_CALLBACK"\]="ide_callback;([^"]+)"', text) assert match, "could not find the PG_ROLE_IDE_CALLBACK entry in run-worker.sh" assert AGENT_KV_CALLBACK_QUEUE in _queues(match.group(1)) + + +# -------------------------------------------------------------------------- +# Traefik routing. Same family of failure as an unconsumed queue: the service +# is up, the route looks configured, and the request lands somewhere that +# cannot serve it. +# +# `/agent-kv/` is mounted by `backend/backend/base_urls.py`, but traefik's +# backend rule matched only `/api/v1`, `/deployment` and `/public`, and the +# frontend rule is the negation of exactly those -- so every Agent-KV request +# through the compose stack was served by the SPA's nginx and never reached +# Django. The e2e lane did not catch it because it talks to +# `UNSTRACT_BACKEND_URL` (port 8000) directly, bypassing traefik entirely. +# +# Reported as 2.2 in the branch review. +# -------------------------------------------------------------------------- + +AGENT_KV_PREFIX = "/agent-kv" + + +def _router_rule(compose_path: Path, router: str) -> str: + svc_labels = [] + for svc in yaml.safe_load(compose_path.read_text())["services"].values(): + svc_labels.extend(svc.get("labels") or []) + prefix = f"traefik.http.routers.{router}.rule=" + for label in svc_labels: + if isinstance(label, str) and label.startswith(prefix): + return label[len(prefix) :] + pytest.fail(f"no traefik rule for router {router!r} in {compose_path.name}") + + +def test_traefik_routes_agent_kv_to_the_backend(): + rule = _router_rule(DEV_COMPOSE, "backend") + assert f"PathPrefix(`{AGENT_KV_PREFIX}`)" in rule, ( + "traefik does not route /agent-kv to the backend; the request is served " + "by the frontend SPA and never reaches Django" + ) + + +def test_traefik_excludes_agent_kv_from_the_frontend(): + """The frontend rule is a negation list, so a prefix missing from it is + claimed by the SPA even once the backend also matches -- whichever router + wins, one of them is wrong. + """ + rule = _router_rule(DEV_COMPOSE, "frontend") + assert f"!PathPrefix(`{AGENT_KV_PREFIX}`)" in rule, ( + "the frontend router still claims /agent-kv; it must be excluded " + "explicitly, exactly as /api/v1, /deployment and /public are" + ) + + +def test_every_backend_prefix_is_excluded_from_the_frontend(): + """The two rules are each other's complement by construction. Asserting the + relationship rather than one hardcoded prefix means the next mount point + cannot be added to one side only. + """ + backend_rule = _router_rule(DEV_COMPOSE, "backend") + frontend_rule = _router_rule(DEV_COMPOSE, "frontend") + + backend_prefixes = set(re.findall(r"PathPrefix\(`([^`]+)`\)", backend_rule)) + excluded = set(re.findall(r"!PathPrefix\(`([^`]+)`\)", frontend_rule)) + + missing = backend_prefixes - excluded + assert not missing, ( + f"{sorted(missing)} route to the backend but are not excluded from the " + f"frontend router; the SPA will claim them" + ) From 4a20ef714be12bdff2e8ffdbd0295b4176305674 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 15:06:30 +0530 Subject: [PATCH 08/54] =?UTF-8?q?UN-4232=20[FIX]=202.5,=202.9,=202.11,=202?= =?UTF-8?q?.12=20=E2=80=94=20an=20over-subscribed=20concurrency=20cap=20an?= =?UTF-8?q?d=20three=20silent=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.5 — CANCEL RELEASED THE SLOT WITHOUT STOPPING THE EXECUTOR `mark_terminal` excludes only TERMINAL, so cancelling a DISPATCHED/RUNNING job returned won=True and the slot was released immediately. Nothing revokes a running executor -- `job.task_id` is written at dispatch and never read again -- so the engine kept running, kept calling LLMs and kept billing while its slot was handed to the next submit. Submit-then-cancel in a loop therefore ran arbitrarily many concurrent extractions against a ceiling of AGENT_KV_CONCURRENT_LIMIT, all paid for. This is a cost bug, not just a bookkeeping one. Release is now narrowed to jobs that never reached an executor, via a `_never_dispatched` helper applied at both cancel sites. Narrowing loses nothing: a mid-run cancel's slot is released by FinalizeView's `finally` when the callback lands, and release() is idempotent. `test_delete_on_running_job_releases_the_concurrency_slot` asserted the release -- it pinned the bug. Inverted rather than deleted, with the positive case (never-dispatched DOES release) added alongside, plus a unit test for the PENDING-but-dispatched edge: post-enqueue bookkeeping can fail after the task is queued, leaving a row that reads PENDING while an executor runs, and releasing that would over-subscribe exactly as before. 2.9 — maintenance.py HAD NO LOGGER AT ALL Not "logged too little": no `import logging`, no logger, in the module that can terminalize a thousand jobs as FAILED in one call. A backlog of stranded jobs was indistinguishable from a quiet, healthy system. Both entry points now report what they did, at WARNING when the counts are non-zero (jobs WERE stranded and slots WERE held) and INFO when there was nothing to do. The scheduler proxies log their result too -- counts alone cannot distinguish "ran and found nothing" from "never ran", which is precisely the state this task pair shipped in. Also pinned `retained` in the TTL return shape. The test asserted `{"cleaned": 2}` while the endpoint returns `{"cleaned": N, "retained": M}`, so the field that counts files it could NOT delete was outside the contract. 2.11 — `except Exception: pass` LOST THE REAL EXECUTOR ERROR An empty catch with no log, around the result-backend lookup. The common case is a kombu deserialization failure: the executor raised a cloud-plugin exception class not importable in the OSS ide_callback image, so the error exists and this process cannot read it. The job was then finalized with "Executor failed without an error message" -- the exact useless error the lookup exists to avoid -- and the cause was unrecoverable even from logs. 2.12 — AN UNKNOWN JOB AND A DUPLICATE RETURNED THE SAME 200 FinalizeView and StageReportView both answered a byte-identical 200 no-op whether the job was already terminal (ordinary) or no row matched the job/org pair at all (never ordinary). The module imported `logger` and never called it. That second case is what an org-slug-vs-FK-pk mix-up looks like, and dispatch.py documents that exact confusion shipping once already. If it recurs, every finalize is a silent no-op, every job stays non-terminal and is reaped as "Job timed out": a 100% failure rate presenting as timeouts, with nothing anywhere. Both views now log the unknown-job case and return a `reason` field so the worker can tell them apart. Three finalize tests asserted the exact response dict and gained the new key -- an additive contract change, not a loosened assertion. 221 backend tests (from 218), ide_callback 20, scheduler 7, queue wiring 12. Every fix mutation-checked by reintroducing the original behaviour. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 80 ++++++++++---- backend/agent_kv/internal_views.py | 41 ++++++- backend/agent_kv/maintenance.py | 30 +++++ backend/agent_kv/tests/test_internal_views.py | 78 ++++++++++++- backend/agent_kv/tests/test_job_views.py | 103 +++++++++++++++++- workers/ide_callback/agent_kv_tasks.py | 14 ++- workers/scheduler/agent_kv_tasks.py | 12 +- .../tests/test_agent_kv_scheduler_tasks.py | 11 +- 8 files changed, 338 insertions(+), 31 deletions(-) diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index b6b476af0c..d3a4821412 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -89,6 +89,18 @@ def _status_document(job) -> dict: return doc +def _never_dispatched(job) -> bool: + """True when this job never reached an executor, so no callback will come. + + Read from the row as it was BEFORE this request terminalized it. Both + conditions are deliberate: `dispatched_at` is stamped by post-enqueue + bookkeeping that can legitimately fail, and `status` is what the dispatcher + advances -- a job with either marker set has, or may have, a running + executor whose finalize callback owns the slot. + """ + return job.dispatched_at is None and job.status == JobStatus.PENDING + + def _fail_job_response(job, org_id: str, message: str, *, job_saved: bool) -> Response: """Shared cleanup for any post-acquire submit failure (spec §5.3/§5.4). @@ -343,20 +355,35 @@ def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): job.id, job.organization_id, JobStatus.CANCELLED ) if won: - # Release the concurrency slot, exactly as JobCancelView does - # and for the same reason: the slot is taken at submit and - # released by _fail_job_response, the finalize callback and the - # sweep -- but a job terminalized HERE before dispatch gets no - # finalize callback, and the sweep's phase-1 only targets - # PENDING, never CANCELLED. Without this, deleting in-flight - # jobs leaks a slot each until the 6h TTL, and enough deletes - # exhaust the org's allowance and start rejecting new submits. + # Release the concurrency slot ONLY for a job that was never + # dispatched. + # + # The slot is taken at submit and released by + # `_fail_job_response`, the finalize callback and the sweep. A + # job cancelled BEFORE dispatch gets no finalize callback, and + # the sweep's phase-1 only targets PENDING, never CANCELLED -- + # so without this release its slot would leak until the 6h TTL. + # That is what this release is for, and all it is for. + # + # It used to fire for ANY non-terminal job, including one + # mid-run. Nothing revokes a running executor -- `job.task_id` + # is written at dispatch and never read again -- so the engine + # kept running, kept calling LLMs and kept billing while its + # slot was handed to the next submit. Submit-then-cancel in a + # loop therefore ran arbitrarily many concurrent extractions + # against a ceiling of `AGENT_KV_CONCURRENT_LIMIT`, all paid for. + # + # Narrowing loses nothing: a mid-run cancel's slot is released + # by `FinalizeView`'s `finally` when the executor's callback + # lands, and `release()` is idempotent (zrem). # # Guarded on `won` so a lost race (a concurrent cancel or # finalize terminalized it first) does not release a slot that - # the winner is still accounting for; release() is idempotent - # (zrem), so the winner's own release stays correct. - AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) + # the winner is still accounting for. + if _never_dispatched(job): + AgentKVConcurrencyLimiter.release( + str(job.organization_id), str(job.id) + ) # This request owns the terminal notification: no fresh finalize # can follow a cancel that won, so nothing else will send it. dispatch_cancelled_webhook(job) @@ -423,14 +450,29 @@ def post(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): job = _get_job(agent_kv_key, job_id) won = AgentKVJob.mark_terminal(job.id, job.organization_id, JobStatus.CANCELLED) if won: - # The slot is acquired at submit and released by _fail_job_response, - # the finalize callback, and the sweep. A job cancelled BEFORE - # dispatch gets no finalize callback, and the sweep's phase-1 only - # targets PENDING (not CANCELLED) -- so without this release its - # slot would leak until the 6h TTL (pre-Greptile important #4). - # release() is idempotent (zrem), so a later finalize-callback - # release on a cancel-mid-run is harmless. - AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) + # Release the concurrency slot ONLY for a job that was never + # dispatched. + # + # The slot is taken at submit and released by + # `_fail_job_response`, the finalize callback and the sweep. A + # job cancelled BEFORE dispatch gets no finalize callback, and + # the sweep's phase-1 only targets PENDING, never CANCELLED -- + # so without this release its slot would leak until the 6h TTL. + # That is what this release is for, and all it is for. + # + # It used to fire for ANY non-terminal job, including one + # mid-run. Nothing revokes a running executor -- `job.task_id` + # is written at dispatch and never read again -- so the engine + # kept running, kept calling LLMs and kept billing while its + # slot was handed to the next submit. Submit-then-cancel in a + # loop therefore ran arbitrarily many concurrent extractions + # against a ceiling of `AGENT_KV_CONCURRENT_LIMIT`, all paid for. + # + # Narrowing loses nothing: a mid-run cancel's slot is released + # by `FinalizeView`'s `finally` when the executor's callback + # lands, and `release()` is idempotent (zrem). + if _never_dispatched(job): + AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) # Docs §8 promises a terminal notification, and cancellation never # reaches finalize -- so this path has to send it. Guarded on `won` # so a cancel that LOST leaves the notification to the finalize that diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index 606b0af63a..40f446e84f 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -96,9 +96,31 @@ def post(self, request, *args, job_id=None, **kwargs): ) job = job_qs.first() if job is None: - # Late report for a job that already reached a terminal state - # (completed/failed/cancelled) -- a no-op, not an error. - return Response({"ok": True, "noop": True}) + # Two very different situations used to share this response: a late + # report for an already-terminal job (ordinary), and a report for a + # job_id/org_id pair that matches NO row at all (never ordinary). + # + # The second is what an org-slug-vs-FK-pk mix-up looks like, and + # `dispatch.py` documents that exact confusion shipping once + # already. If it recurs, every stage report is a silent 200 no-op, + # every job stays non-terminal and gets reaped as "Job timed out" -- + # a 100% failure rate presenting as timeouts, with nothing in any + # log. So say which one happened. + exists = AgentKVJob.objects.filter(id=job_id, organization_id=org_id).exists() + if not exists: + logger.warning( + "agent-kv stage report for unknown job %s (org=%s); no row " + "matches that job/org pair -- check the org identifier", + job_id, + org_id, + ) + return Response( + { + "ok": True, + "noop": True, + "reason": "already_terminal" if exists else "unknown_job", + } + ) entry = {"status": status_value} if "seconds" in body: @@ -230,11 +252,24 @@ def post(self, request, *args, job_id=None, **kwargs): finally: AgentKVConcurrencyLimiter.release(org_id, str(job_id)) + if job is None: + logger.warning( + "agent-kv finalize for unknown job %s (org=%s); no row matches " + "that job/org pair, so nothing was terminalized -- the job (if " + "it exists under another org identifier) will be reaped as a " + "timeout", + job_id, + org_id, + ) + return Response( { "finalized": finalized, "webhook_url": job.webhook_url if job else "", "status": job.status.lower() if job else "", + # Lets the callback worker tell "already terminal" (ordinary) + # from "no such job" (a wiring fault) -- they were byte-identical. + "reason": "ok" if job else "unknown_job", } ) diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index a448030849..42154fcf65 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -19,6 +19,7 @@ single-row write, never a queryset-wide one. """ +import logging from datetime import timedelta from django.conf import settings @@ -29,6 +30,8 @@ from agent_kv.rate_limiter import AgentKVConcurrencyLimiter from agent_kv.storage import delete_job_files +logger = logging.getLogger(__name__) + _MAINTENANCE_BATCH_LIMIT = 500 # Slots in each TTL-cleanup batch held for rows whose file delete failed before. # Caps the retry lane (so failures cannot crowd out new expirations) and @@ -152,6 +155,21 @@ def run_sweep() -> dict: timed_out += 1 AgentKVConcurrencyLimiter.release(str(org_id), str(job.id)) + # These two counts are the only evidence the sweep ran and the only + # evidence it had to do anything. A sweep that terminalizes a thousand jobs + # as FAILED used to emit nothing at all -- the module has had no logger + # since it was written -- so a backlog of stranded jobs looked identical to + # a quiet, healthy system. + if swept or timed_out: + logger.warning( + "agent-kv sweep terminalized %s never-dispatched and %s stuck job(s); " + "a non-zero count here means jobs were stranded and their " + "concurrency slots held", + swept, + timed_out, + ) + else: + logger.info("agent-kv sweep: nothing to terminalize") return {"swept": swept, "timed_out": timed_out} @@ -246,4 +264,16 @@ def run_ttl_cleanup() -> dict: # stays high across ticks is the signal that something is wrong with the # object store rather than with one job -- the split stops either side # blocking the other, it does not make a persistent fault harmless. + # `retained` is the count this pass could NOT clean -- a file delete that + # failed keeps its ref so the next pass retries it. A `retained` that never + # falls is a stuck object, and stays invisible without this. + if retained: + logger.warning( + "agent-kv TTL cleanup removed %s job(s) and RETAINED %s whose files " + "could not be deleted; those refs are kept for the next pass", + cleaned, + retained, + ) + elif cleaned: + logger.info("agent-kv TTL cleanup removed %s expired job(s)", cleaned) return {"cleaned": cleaned, "retained": retained} diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py index c0c4fbf9ae..5d91b6bcad 100644 --- a/backend/agent_kv/tests/test_internal_views.py +++ b/backend/agent_kv/tests/test_internal_views.py @@ -365,6 +365,7 @@ def test_finalize_success_writes_result_then_marks_terminal( "finalized": True, "webhook_url": "https://example.com/hook", "status": "completed", + "reason": "ok", } assert [c[0] for c in manager.mock_calls] == ["write_result", "mark_terminal"] m_write.assert_called_once_with("org1", str(job_id), {"foo": "bar"}) @@ -532,6 +533,7 @@ def test_finalize_duplicate_does_not_rewrite_result( "finalized": False, "webhook_url": "https://example.com/hook", "status": "completed", + "reason": "ok", } assert not m_write.called assert not m_mark_terminal.called @@ -584,7 +586,12 @@ def test_finalize_failure_records_error(m_jobs, m_write, m_mark_terminal, m_rele ) assert resp.status_code == 200 - assert resp.data == {"finalized": True, "webhook_url": "", "status": "failed"} + assert resp.data == { + "finalized": True, + "webhook_url": "", + "status": "failed", + "reason": "ok", + } assert not m_write.called m_mark_terminal.assert_called_once_with( job_id, "org1", JobStatus.FAILED, error="LLM provider timed out" @@ -684,3 +691,72 @@ def test_frozen_internal_urls_resolve_to_the_right_views(): finalize = resolve(f"/internal/v1/agent-kv/jobs/{job_id}/finalize/") assert finalize.func.cls is iv.FinalizeView assert finalize.kwargs == {"job_id": job_id} + + +# --------------------------------------------------------------------------- +# An unknown job/org pair must be distinguishable from an ordinary duplicate. +# +# Both used to return a byte-identical 200 no-op with nothing logged. The +# second is what an org-slug-vs-FK-pk mix-up looks like -- and `dispatch.py` +# documents that exact confusion shipping once already. If it recurs, every +# finalize is a silent no-op, every job stays non-terminal and is reaped as +# "Job timed out": a 100% failure rate presenting as timeouts, invisible. +# +# Reported as 2.12 in the branch review. +# --------------------------------------------------------------------------- +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_for_an_unknown_job_says_so_and_logs(m_objects, m_release, caplog): + import logging + + m_objects.filter.return_value.first.return_value = None + + with caplog.at_level(logging.WARNING, logger="agent_kv.internal_views"): + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True}), job_id=uuid.uuid4() + ) + + assert resp.status_code == 200 + assert resp.data["reason"] == "unknown_job" + assert resp.data["finalized"] is False + assert "unknown job" in caplog.text + + +@mock.patch.object(AgentKVJob, "objects") +def test_stage_report_for_an_unknown_job_says_so_and_logs(m_objects, caplog): + """`already_terminal` is ordinary and stays quiet; `unknown_job` is not.""" + import logging + + m_objects.filter.return_value.exclude.return_value.first.return_value = None + m_objects.filter.return_value.exists.return_value = False + + with caplog.at_level(logging.WARNING, logger="agent_kv.internal_views"): + resp = iv.StageReportView.as_view()( + _post( + "/x", {"org_id": "org1", "stage": "table_extraction", "status": "running"} + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 200 + assert resp.data["reason"] == "unknown_job" + assert "unknown job" in caplog.text + + +@mock.patch.object(AgentKVJob, "objects") +def test_stage_report_for_an_already_terminal_job_is_quiet(m_objects, caplog): + import logging + + m_objects.filter.return_value.exclude.return_value.first.return_value = None + m_objects.filter.return_value.exists.return_value = True + + with caplog.at_level(logging.WARNING, logger="agent_kv.internal_views"): + resp = iv.StageReportView.as_view()( + _post( + "/x", {"org_id": "org1", "stage": "table_extraction", "status": "running"} + ), + job_id=uuid.uuid4(), + ) + + assert resp.data["reason"] == "already_terminal" + assert caplog.records == [], "a late report for a finished job is ordinary" diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index dc1890111c..b2ff721645 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -390,12 +390,56 @@ def test_delete_on_running_job_cancels_before_deleting_files( @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_on_running_job_releases_the_concurrency_slot( +def test_delete_on_a_running_job_does_NOT_release_the_slot( m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release ): + """Inverted: this used to assert the release, which was the bug. + + Nothing revokes a running executor -- `job.task_id` is written at dispatch + and never read again -- so the engine keeps running, keeps calling LLMs and + keeps billing. Handing its slot back immediately let the next submit start + alongside it, so submit-then-delete in a loop ran arbitrarily many + concurrent extractions against a ceiling of AGENT_KV_CONCURRENT_LIMIT, all + paid for. + + The slot is released by `FinalizeView`'s `finally` when the executor's + callback lands. Reported as 2.5 in the branch review. + """ m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.RUNNING) job.organization_id = "org1" + job.dispatched_at = timezone.now() + m_jobs.get.return_value = job + + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + assert not m_release.called, ( + "the executor is still running and still billing; its slot belongs to " + "the finalize callback, not to this request" + ) + + +@mock.patch.object(ev, "dispatch_cancelled_webhook") +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_on_a_never_dispatched_job_DOES_release_the_slot( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook +): + """The case the release exists for, and the only one. + + A job cancelled before dispatch gets no finalize callback, and the sweep's + phase-1 only targets PENDING, never CANCELLED -- so without this its slot + leaks until the 6h TTL. + """ + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.PENDING) + job.organization_id = "org1" + job.dispatched_at = None m_jobs.get.return_value = job resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) @@ -677,3 +721,60 @@ def test_delete_that_cancels_a_running_job_also_queues_the_webhook( assert resp.status_code == 204 assert m_hook.called + + +# --------------------------------------------------------------------------- +# (8k) The same narrowing on JobCancelView, which has the identical shape. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "dispatch_cancelled_webhook") +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_cancel_on_a_running_job_does_NOT_release_the_slot( + m_keys, m_jobs, m_mark_terminal, m_release, m_hook +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.RUNNING) + job.organization_id = "org1" + job.dispatched_at = timezone.now() + m_jobs.get.return_value = job + + resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + assert not m_release.called + + +@mock.patch.object(ev, "dispatch_cancelled_webhook") +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_cancel_on_a_never_dispatched_job_DOES_release_the_slot( + m_keys, m_jobs, m_mark_terminal, m_release, m_hook +): + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.PENDING) + job.organization_id = "org1" + job.dispatched_at = None + m_jobs.get.return_value = job + + resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + m_release.assert_called_once_with("org1", str(job.id)) + + +def test_a_dispatched_but_still_pending_job_is_not_treated_as_undispatched(): + """`dispatched_at` and `status` are checked together on purpose. + + Post-enqueue bookkeeping can fail after the task is already on the queue + (dispatch.py wraps it precisely because losing it must not fail the + dispatch), leaving a row that still reads PENDING while an executor runs. + Releasing that slot would over-subscribe exactly as the RUNNING case did. + """ + job = AgentKVJob(status=JobStatus.PENDING) + job.dispatched_at = timezone.now() + + assert not ev._never_dispatched(job) diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index 615434bc79..65aba8f73a 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -80,7 +80,19 @@ def _resolve_error(failed_task_id: str, explicit: str | None = None) -> str: if res.result: return str(res.result) except Exception: - pass + # Was a bare `pass`. The common case here is a kombu deserialization + # failure: the executor raised a cloud-plugin exception class that is + # not importable in the OSS ide_callback image, so the result backend + # has the error and this process cannot read it. The job is then + # finalized with "Executor failed without an error message" -- the exact + # useless error this lookup exists to avoid -- and silently, so the real + # cause was unrecoverable even from logs. + logger.warning( + "Could not read the executor error from the result backend for " + "task %s; the job will be finalized with a generic message", + failed_task_id, + exc_info=True, + ) return _UNKNOWN diff --git a/workers/scheduler/agent_kv_tasks.py b/workers/scheduler/agent_kv_tasks.py index 0f266f7e46..6806299c14 100644 --- a/workers/scheduler/agent_kv_tasks.py +++ b/workers/scheduler/agent_kv_tasks.py @@ -62,10 +62,18 @@ def _get_api_client(): @worker_task(name="agent_kv.sweep") def agent_kv_sweep() -> dict[str, Any]: """Terminalize never-dispatched PENDING and stuck Agent-KV jobs (spec §5.4).""" - return _get_api_client().agent_kv_sweep() + result = _get_api_client().agent_kv_sweep() + # Logged here as well as backend-side: this is the only place that records + # the periodic actually FIRED. The counts alone cannot distinguish "ran and + # found nothing" from "never ran", which is precisely the failure this + # task pair shipped with (see the backend migration that schedules them). + logger.info("agent_kv.sweep completed: %s", result) + return result @worker_task(name="agent_kv.ttl_cleanup") def agent_kv_ttl_cleanup() -> dict[str, Any]: """Delete staged Agent-KV input/result files past their TTL (spec §5.4).""" - return _get_api_client().agent_kv_ttl_cleanup() + result = _get_api_client().agent_kv_ttl_cleanup() + logger.info("agent_kv.ttl_cleanup completed: %s", result) + return result diff --git a/workers/tests/test_agent_kv_scheduler_tasks.py b/workers/tests/test_agent_kv_scheduler_tasks.py index 7718a63f77..1cd3f939c7 100644 --- a/workers/tests/test_agent_kv_scheduler_tasks.py +++ b/workers/tests/test_agent_kv_scheduler_tasks.py @@ -63,11 +63,14 @@ def test_sweep_calls_the_sweep_client_method(self): def test_ttl_cleanup_calls_the_ttl_cleanup_client_method(self): mock_api = MagicMock() - mock_api.agent_kv_ttl_cleanup.return_value = {"cleaned": 2} + mock_api.agent_kv_ttl_cleanup.return_value = {"cleaned": 2, "retained": 1} with patch.object(akt, "_get_api_client", return_value=mock_api): result = akt.agent_kv_ttl_cleanup() mock_api.agent_kv_ttl_cleanup.assert_called_once_with() - assert result == {"cleaned": 2} + # `retained` is part of the contract: it counts jobs whose files could not + # be deleted and whose refs are kept for the next pass. Pinned so it cannot + # be dropped silently. + assert result == {"cleaned": 2, "retained": 1} class TestInternalClientAgentKvMethods: @@ -87,9 +90,9 @@ def test_agent_kv_sweep_posts_to_the_sweep_endpoint(self): def test_agent_kv_ttl_cleanup_posts_to_the_ttl_cleanup_endpoint(self): with patch.object( - InternalAPIClient, "post", return_value={"cleaned": 1} + InternalAPIClient, "post", return_value={"cleaned": 1, "retained": 0} ) as m_post: client = InternalAPIClient.__new__(InternalAPIClient) result = client.agent_kv_ttl_cleanup() m_post.assert_called_once_with("v1/agent-kv/ttl-cleanup/", data={}) - assert result == {"cleaned": 1} + assert result == {"cleaned": 1, "retained": 0} From 835a46d7b35c00a6820324b6cb15d294d82329ed Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 15:22:31 +0530 Subject: [PATCH 09/54] =?UTF-8?q?UN-4232=20[FIX]=202.6,=202.7,=202.8,=202.?= =?UTF-8?q?13,=202.14,=202.15=20=E2=80=94=20correctness=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.14 — THE SWEEP'S NULL BACKSTOP COULD NEVER FIRE UNDER BACKLOG Phase 2's second Q arm exists to recover rows whose post-enqueue bookkeeping was lost -- they have `dispatched_at IS NULL`. But the batch was `.order_by("dispatched_at")[:500]`, and Postgres sorts ascending NULLS LAST, so with 500 non-NULL stuck rows ahead of them those rows were never selected. The backstop could not fire in exactly the situation it exists for. Now orders by `Coalesce("dispatched_at", "created_at")`. The test asserted `order_by("dispatched_at")` -- it pinned the bug -- so it is inverted with the reasoning recorded rather than deleted. 2.15 — TTL CLEANUP DELETED THE INPUT OF A RUNNING JOB The candidate query filtered on `expires_at` and a non-blank ref, with no status filter, so a job still RUNNING past its TTL had its staged input deleted out from under the executor. The TTL is a retention policy for finished work, not a kill switch for running work. Reachable whenever a job is stuck non-terminal longer than AGENT_KV_RESULT_TTL_DAYS -- which is what the sweep's phase 2 exists to catch, and which until this branch was never scheduled at all. TERMINAL-only now. The sweep terminalizes stuck jobs first; then this cleans them. The mocks follow the new three-filter chain and the status predicate is asserted, so it cannot be dropped silently. 2.13 — UNTRUSTED COUNTERS COULD REWRITE THE STATUS DOCUMENT `_status_document` builds each stage entry as `{"name": name, **entry}` with the spread LAST, so a persisted `name` counter overrode it -- renaming the stage in every later status response and breaking the `if name in stages_json` filter that decides which stages are shown. One counter could make a job's progress disappear. `name` is now reserved alongside `status`/`seconds`. Two more on the same endpoint: `seconds` was taken verbatim, so a dict or list was persisted and echoed back to the caller (now type-checked, with `bool` excluded since it subclasses `int`); and `stage` is varchar(32), so a longer name was an unhandled 500 and `job.stages` could grow unbounded distinct keys. 2.7 — THE `extractors` FILE PART WAS MATERIALISED BEFORE ANY SIZE CHECK The cap lives in `validate_extractors`, i.e. after the read, and `DATA_UPLOAD_MAX_MEMORY_SIZE` excludes file-typed parts -- so a 500 MB part was read in full (plus up to 4x again for the `str`) before being rejected at 256 KiB. One request per worker process OOMs the pod: a cheap denial of service. Now reads `limit + 1` bytes. Also `errors="strict"`: a latin-1 key name used to decode to U+FFFD and then compile cleanly, so a malformed payload became a job running against a schema the caller never wrote. 2.8 — A DB FAILURE AFTER STAGING ORPHANED THE UPLOAD PERMANENTLY If `stage_input` succeeded and `job.save()` raised, the upload existed with no row to carry its ref -- and `run_ttl_cleanup` selects candidates from AgentKVJob rows, so an object with no row is structurally unreachable by every cleanup path there is. Customer data in the bucket that nobody can find or delete. Now deleted on that path, with its own failure logged rather than swallowed. 2.6 — AN E2E ASSERTION AND THE DOCS BOTH ASSERTED THE WRONG CASE Both claimed the cancel 409 body carries the RAW uppercase enum, and the docs pre-empted correction with "not a typo in this document", citing a unit test as proof. `JobCancelView` returns `job.status.lower()`, and the cited test asserts `{"status": "completed"}`. The e2e lane had never run, so the wrong assertion was never executed. Both corrected, and the docs now record that they were wrong rather than quietly flipping. 228 backend tests (from 221). 2.8, 2.14 and 2.15 mutation-checked. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 43 ++++++- backend/agent_kv/internal_views.py | 22 +++- backend/agent_kv/maintenance.py | 22 +++- backend/agent_kv/tests/test_internal_views.py | 74 ++++++++++++ backend/agent_kv/tests/test_submit_view.py | 107 ++++++++++++++++++ backend/agent_kv/tests/test_sweeps.py | 26 ++++- docs/agent-kv-api.md | 15 ++- tests/e2e/agent_kv/test_agent_kv_e2e.py | 11 +- 8 files changed, 301 insertions(+), 19 deletions(-) diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index d3a4821412..d9fe362fc0 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -5,6 +5,7 @@ from django.conf import settings from django.utils import timezone from plugins import get_plugin +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from rest_framework.views import APIView @@ -230,7 +231,31 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): data = request.data.copy() part = data.get("extractors") if hasattr(part, "read"): # `extractors` uploaded as a file part (§7.1) - data["extractors"] = part.read().decode("utf-8", errors="replace") + # Bounded read. The size cap lives in `validate_extractors`, i.e. + # AFTER this point -- and `DATA_UPLOAD_MAX_MEMORY_SIZE` excludes + # file-typed parts, so a 500 MB `extractors` part was materialised + # in full (plus up to 4x that again for the `str`) before being + # rejected at 256 KiB. One such request per worker process OOMs the + # pod, which makes it a cheap denial of service. + # + # Read one byte past the cap: enough to know it is over without + # ever holding more than the cap plus one. + limit = settings.AGENT_KV_MAX_SCHEMA_BYTES + raw = part.read(limit + 1) + if len(raw) > limit: + raise ValidationError( + {"extractors": f"extractors payload exceeds {limit} bytes"} + ) + try: + # `errors="strict"`, not "replace". A latin-1 key name used to + # decode to U+FFFD and then compile cleanly, so a malformed + # payload became a job that ran against a schema the caller + # never wrote. + data["extractors"] = raw.decode("utf-8") + except UnicodeDecodeError: + raise ValidationError( + {"extractors": "extractors must be valid UTF-8"} + ) from None serializer = SubmitSerializer(data=data) serializer.is_valid(raise_exception=True) v = serializer.validated_data @@ -256,6 +281,22 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): job_saved = True except Exception: logger.exception("agent-kv staging/save failed for job %s", job.id) + # Delete the staged object before responding. If `stage_input` + # succeeded and `job.save()` then raised, the upload exists with no + # row to carry its ref -- and `run_ttl_cleanup` selects candidates + # from `AgentKVJob` rows, so an object with no row is structurally + # unreachable by every cleanup path there is. It would sit in the + # bucket forever, holding customer data nobody can find or delete. + if job.input_ref: + try: + delete_job_files(job) + except Exception: + logger.exception( + "agent-kv: could not remove the staged input for failed " + "submit %s; it is now orphaned (no job row carries its " + "ref, so TTL cleanup cannot reach it)", + job.id, + ) return _fail_job_response( job, org_id, diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index 40f446e84f..6d8206b4ad 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -23,7 +23,13 @@ logger = logging.getLogger(__name__) _VALID_STAGE_STATUSES = frozenset({"running", "done"}) -_RESERVED_STAGE_ENTRY_KEYS = frozenset({"status", "seconds"}) +# `name` is reserved too: `_status_document` builds each stage entry as +# `{"name": name, **stages_json[name]}`, and the spread comes LAST -- so a +# persisted `name` counter overrides it. That renames the stage in every later +# status response and breaks the `if name in stages_json` filter the document +# uses to decide which stages to show, i.e. one counter can make a job's +# progress disappear. +_RESERVED_STAGE_ENTRY_KEYS = frozenset({"status", "seconds", "name"}) _SCALAR_COUNTER_TYPES = (str, int, float, bool) @@ -122,9 +128,21 @@ def post(self, request, *args, job_id=None, **kwargs): } ) + # `job.stage` is varchar(32). A longer name was an unhandled 500, and + # `job.stages` could grow unbounded distinct keys from a misbehaving + # or malicious executor. + if len(stage) > 32: + return Response({"detail": "stage name too long"}, status=400) + entry = {"status": status_value} if "seconds" in body: - entry["seconds"] = body["seconds"] + # Type-checked rather than taken verbatim: this is untrusted + # executor payload and it is echoed back in the status document, + # so a dict or list here was persisted and returned to the caller. + seconds = body["seconds"] + if not isinstance(seconds, (int, float)) or isinstance(seconds, bool): + return Response({"detail": "seconds must be a number"}, status=400) + entry["seconds"] = seconds entry.update(_sanitize_counters(body.get("counters"))) updates = { diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index 42154fcf65..6ea8845a6a 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -24,6 +24,7 @@ from django.conf import settings from django.db.models import Q +from django.db.models.functions import Coalesce from django.utils import timezone from agent_kv.models import AgentKVJob, JobStatus @@ -139,7 +140,13 @@ def run_sweep() -> dict: Q(dispatched_at__lt=stuck_cutoff) | Q(dispatched_at__isnull=True, created_at__lt=stuck_cutoff) ) - .order_by("dispatched_at")[:_MAINTENANCE_BATCH_LIMIT] + # Coalesce, not a bare `dispatched_at`. The second Q arm exists to + # recover rows whose post-enqueue bookkeeping was lost -- they have + # `dispatched_at IS NULL` -- but Postgres sorts ascending NULLS LAST, + # so with a full batch of non-NULL stuck rows ahead of them those rows + # were never selected. The backstop could not fire in exactly the + # situation it exists for: a backlog. + .order_by(Coalesce("dispatched_at", "created_at"))[:_MAINTENANCE_BATCH_LIMIT] ) timed_out = 0 @@ -210,8 +217,17 @@ def run_ttl_cleanup() -> dict: row before applying the limit, which grows with the backlog. """ now = timezone.now() - expired = AgentKVJob.objects.filter(expires_at__lt=now).filter( - Q(input_ref__gt="") | Q(result_ref__gt="") + # TERMINAL only. Without this, a job still RUNNING past `expires_at` had + # its staged input deleted out from under the executor -- the TTL is a + # retention policy for finished work, not a kill switch for running work. + # Reachable whenever a job is stuck non-terminal for longer than + # AGENT_KV_RESULT_TTL_DAYS, which is precisely what the sweep's phase 2 + # exists to catch; the sweep terminalizes those first, and then this + # cleans them. + expired = ( + AgentKVJob.objects.filter(expires_at__lt=now) + .filter(status__in=list(AgentKVJob.TERMINAL)) + .filter(Q(input_ref__gt="") | Q(result_ref__gt="")) ) # Retries first, capped at the reserve so they cannot crowd out fresh work. diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py index 5d91b6bcad..20cba4c677 100644 --- a/backend/agent_kv/tests/test_internal_views.py +++ b/backend/agent_kv/tests/test_internal_views.py @@ -760,3 +760,77 @@ def test_stage_report_for_an_already_terminal_job_is_quiet(m_objects, caplog): assert resp.data["reason"] == "already_terminal" assert caplog.records == [], "a late report for a finished job is ordinary" + + +# --------------------------------------------------------------------------- +# Untrusted executor payload must not be able to rewrite the status document. +# Reported as 2.13 in the branch review. +# --------------------------------------------------------------------------- +def test_a_name_counter_cannot_rename_the_stage(): + """`_status_document` builds `{"name": name, **entry}` with the spread + LAST, so a persisted `name` wins -- renaming the stage in every later status + response and breaking the `if name in stages_json` filter that decides + which stages are shown at all. + """ + assert "name" in iv._RESERVED_STAGE_ENTRY_KEYS + assert iv._sanitize_counters({"name": "pwned", "steps": 3}) == {"steps": 3} + + +@mock.patch.object(AgentKVJob, "objects") +def test_a_non_numeric_seconds_is_rejected(m_objects): + """`seconds` is echoed back in the status document, so a dict or list here + was persisted and returned to the caller verbatim. + """ + resp = iv.StageReportView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "stage": "table_extraction", + "status": "running", + "seconds": {"nested": "payload"}, + }, + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 400 + assert "seconds" in str(resp.data) + + +@mock.patch.object(AgentKVJob, "objects") +def test_a_boolean_seconds_is_rejected(m_objects): + """`bool` is a subclass of `int`, so a bare isinstance check would let + `True` through and persist it as a duration. + """ + resp = iv.StageReportView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "stage": "table_extraction", + "status": "running", + "seconds": True, + }, + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 400 + + +@mock.patch.object(AgentKVJob, "objects") +def test_an_overlong_stage_name_is_rejected(m_objects): + """`job.stage` is varchar(32); a longer name was an unhandled 500, and + `job.stages` could grow unbounded distinct keys. + """ + resp = iv.StageReportView.as_view()( + _post( + "/x", + {"org_id": "org1", "stage": "x" * 33, "status": "running"}, + ), + job_id=uuid.uuid4(), + ) + + assert resp.status_code == 400 + assert "too long" in str(resp.data) diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py index 565662fce2..ba105f32dd 100644 --- a/backend/agent_kv/tests/test_submit_view.py +++ b/backend/agent_kv/tests/test_submit_view.py @@ -756,3 +756,110 @@ def test_real_serializer_rejects_the_old_flat_shape_with_400(m_keys, m_plugin, m assert resp.status_code == 400 assert "extractors" in str(resp.data) + + +# --------------------------------------------------------------------------- +# (9) The `extractors` file part is read under a bound, and decoded strictly. +# +# The size cap lives in `validate_extractors`, i.e. AFTER the part was +# materialised, and `DATA_UPLOAD_MAX_MEMORY_SIZE` excludes file-typed parts -- +# so a 500 MB part was read in full before being rejected at 256 KiB. One such +# request per worker process OOMs the pod: a cheap denial of service. +# +# Reported as 2.7 in the branch review. +# --------------------------------------------------------------------------- +from django.core.files.uploadedfile import SimpleUploadedFile # noqa: E402 + + +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) +@mock.patch.object(AgentKVKey, "objects") +def test_an_oversized_extractors_part_is_rejected_by_size(m_keys, m_plugin, m_rate): + """The cap is now consulted BEFORE the part is materialised. + + Note what this does and does not prove: it pins the 400, which is the + behaviour a caller sees. It cannot observe the read size, because the + multipart parser builds its own file object from the wire and the instance + constructed here never reaches the view. The bounded read itself is visible + in `SubmitView.post` as `part.read(limit + 1)`; a test that claimed to + verify it through this path would be asserting on an object the view never + touched. + """ + m_keys.get.return_value = _key_with_org() + cap = 64 + part = SimpleUploadedFile("extractors.json", b"x" * (cap * 50)) + + req = APIRequestFactory().post("/agent-kv/", {"extractors": part}, format="multipart") + req.META["HTTP_AUTHORIZATION"] = "Bearer 123e4567-e89b-12d3-a456-426614174001" + with mock.patch.object(ev.settings, "AGENT_KV_MAX_SCHEMA_BYTES", cap): + resp = ev.SubmitView.as_view()(req) + + assert resp.status_code == 400 + assert "exceeds" in str(resp.data) + + +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) +@mock.patch.object(AgentKVKey, "objects") +def test_a_non_utf8_extractors_part_is_rejected(m_keys, m_plugin, m_rate): + """`errors="replace"` let a latin-1 key name decode to U+FFFD and then + compile cleanly -- a malformed payload became a job that ran against a + schema the caller never wrote. + """ + m_keys.get.return_value = _key_with_org() + + class _Latin1Part: + def read(self, n=-1): + return b'[{"name": "table", "keys": {"target_table": "\xe9"}}]' + + req = APIRequestFactory().post( + "/agent-kv/", {"extractors": _Latin1Part()}, format="multipart" + ) + req.META["HTTP_AUTHORIZATION"] = "Bearer 123e4567-e89b-12d3-a456-426614174001" + resp = ev.SubmitView.as_view()(req) + + assert resp.status_code == 400 + assert "UTF-8" in str(resp.data) + + +# --------------------------------------------------------------------------- +# (10) A DB failure after staging must not orphan the upload. +# +# `run_ttl_cleanup` selects candidates from AgentKVJob rows, so an object whose +# row was never saved is unreachable by every cleanup path there is -- customer +# data sitting in the bucket that nobody can find or delete. +# +# Reported as 2.8 in the branch review. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "delete_job_files") +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "check_and_acquire", return_value=True) +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(AgentKVJob, "save", side_effect=RuntimeError("db down")) +@mock.patch.object(ev, "stage_input", return_value="org1/job/in.pdf") +@mock.patch.object(ev, "SubmitSerializer") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) +@mock.patch.object(AgentKVKey, "objects") +def test_a_save_failure_after_staging_removes_the_staged_object( + m_keys, + m_plugin, + m_rate, + m_serializer_cls, + m_stage, + m_save, + m_mark_terminal, + m_acquire, + m_release, + m_delete, +): + m_keys.get.return_value = _key_with_org() + _mock_serializer(m_serializer_cls) + + resp = ev.SubmitView.as_view()(_authed_post()) + + assert resp.status_code == 500 + assert m_delete.called, ( + "the staged upload was left in the bucket with no job row carrying its " + "ref; TTL cleanup selects from job rows, so it can never be reached" + ) diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py index be1cf6b4f0..13cb1e28f7 100644 --- a/backend/agent_kv/tests/test_sweeps.py +++ b/backend/agent_kv/tests/test_sweeps.py @@ -37,6 +37,7 @@ from django.conf import settings # noqa: E402 from django.db.models import Q # noqa: E402 +from django.db.models.functions import Coalesce # noqa: E402 from django.utils import timezone # noqa: E402 from rest_framework.test import APIRequestFactory # noqa: E402 @@ -224,7 +225,13 @@ def test_stuck_sweep_queries_dispatched_and_running_older_than_stuck_grace( assert age_q == ( Q(dispatched_at__lt=cutoff) | Q(dispatched_at__isnull=True, created_at__lt=cutoff) ) - phase2_qs.order_by.assert_called_once_with("dispatched_at") + # Coalesce, NOT a bare "dispatched_at". This assertion used to pin the + # bare column, which is the bug: Postgres sorts ascending NULLS LAST, so + # with a full batch of non-NULL stuck rows ahead of them, the + # `dispatched_at IS NULL` rows the second Q arm exists to recover were + # never selected. The backstop could not fire in exactly the situation it + # exists for -- a backlog. (2.14 in the branch review.) + phase2_qs.order_by.assert_called_once_with(Coalesce("dispatched_at", "created_at")) phase2_qs.order_by.return_value.__getitem__.assert_called_once_with( slice(None, 500, None) ) @@ -360,9 +367,14 @@ def filter(self, *_args, **kwargs): def _ttl_lanes(m_objects, *, retries=(), fresh=()): - """Wire a mocked ``AgentKVJob.objects`` to both TTL-cleanup lanes.""" + """Wire a mocked ``AgentKVJob.objects`` to both TTL-cleanup lanes. + + Three chained filters now: `expires_at < now`, then TERMINAL-only (2.15 -- + a running job must not have its input deleted out from under it), then the + non-blank-ref Q. + """ lanes = _Lanes(retries, fresh) - m_objects.filter.return_value.filter.return_value = lanes + m_objects.filter.return_value.filter.return_value.filter.return_value = lanes return lanes @@ -383,7 +395,13 @@ def test_ttl_cleanup_queries_expired_jobs_with_a_nonblank_ref(m_objects, m_delet assert resp.status_code == 200 assert resp.data == {"cleaned": 0, "retained": 0} assert m_objects.filter.call_args_list[0].kwargs == {"expires_at__lt": frozen_now} - (q_arg,), q_kwargs = m_objects.filter.return_value.filter.call_args + # TERMINAL-only: a job still RUNNING past its TTL must keep its staged + # input. The retention policy covers finished work, not running work. + status_filter = m_objects.filter.return_value.filter.call_args + assert set(status_filter.kwargs["status__in"]) == set(AgentKVJob.TERMINAL) + (q_arg,), q_kwargs = ( + m_objects.filter.return_value.filter.return_value.filter.call_args + ) assert q_kwargs == {} assert q_arg == (Q(input_ref__gt="") | Q(result_ref__gt="")) # Two lanes off that one narrowed set: retries first, then new expirations. diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 493e13083e..4d6ebfd424 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -540,12 +540,15 @@ metered; work not yet started is not. A job that finishes before the cancel is n simply has its result discarded on arrival. - Won the race (job was non-terminal): `200` `{"status": "cancelled"}`. -- Already terminal: `409` `{"status": ""}` — **note this one is the raw - uppercase enum value** (e.g. `"COMPLETED"`), unlike every lowercased `status` value - elsewhere in this API (status doc, result 409). This is the code as shipped - (`execution_views.py::JobCancelView`, confirmed by - `test_job_views.py::test_cancel_on_completed_is_409_and_result_untouched`), not a - typo in this document. +- Already terminal: `409` `{"status": ""}`, lowercased (e.g. + `"completed"`) — consistent with every other `status` value in this API. + + *An earlier version of this document claimed the opposite: that this one field + carried the raw uppercase enum, and that it was "not a typo". It was wrong. + `JobCancelView` returns `job.status.lower()`, and the unit test cited as proof + (`test_job_views.py::test_cancel_on_completed_is_409_and_result_untouched`) + asserts `{"status": "completed"}`. The e2e lane carried the same wrong + assertion and had never been run.* ```bash curl -X POST https://api.unstract.example/agent-kv/5b6e9b0a-.../cancel \ diff --git a/tests/e2e/agent_kv/test_agent_kv_e2e.py b/tests/e2e/agent_kv/test_agent_kv_e2e.py index edfff5dbf0..6bff877d2e 100644 --- a/tests/e2e/agent_kv/test_agent_kv_e2e.py +++ b/tests/e2e/agent_kv/test_agent_kv_e2e.py @@ -342,9 +342,14 @@ def test_cancel_mid_run(agent_kv_key: AgentKVAuth) -> None: assert cancel_body == {"status": "cancelled"}, cancel_body assert final_status == "cancelled", status_resp.json() else: - # docs §7: the 409 body's status is the RAW uppercase enum value, - # unlike every other status field in this API. - assert cancel_body["status"] == final_status.upper(), (cancel_body, final_status) + # Lowercased, like every other status field in this API. The docs and + # this assertion both used to claim the 409 body carried the RAW + # uppercase enum -- it does not: `JobCancelView` returns + # `job.status.lower()`, and the unit test the docs cited as proof + # (`test_cancel_on_completed_is_409_and_result_untouched`) asserts + # `{"status": "completed"}`. This lane had never run, so the wrong + # assertion was never executed. (2.6 in the branch review.) + assert cancel_body["status"] == final_status, (cancel_body, final_status) def test_cancelled_job_does_not_leak_its_concurrency_slot( From a1c6c9dc4daba5bb7314783e1ea3bb405ab6afb0 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 15:35:28 +0530 Subject: [PATCH 10/54] UN-4232 [FIX] Six Greptile findings on this branch's own new code All six land on code added earlier in this branch, and all six are real. Two of them are consequences of narrowing the slot release, which is worth stating plainly: fixing the over-subscription opened the opposite hole at both ends. 1. CANCELLED BEFORE ENQUEUE -> PAID WORK WITH NO SLOT HELD (P1) A cancel can land between the submit's `job.save()` and `dispatch_job`'s enqueue. The cancel sees a PENDING, never-dispatched row, so it terminalizes it AND releases the slot -- correctly, nothing had been dispatched. But dispatch then enqueued anyway: paid work for a job the caller already cancelled, with its slot already handed to the next submit, so the concurrency ceiling is bypassed too. `dispatch_job` now re-reads the row immediately before enqueueing. Re-read, not `job.status`: the in-memory row predates the cancel by construction. 2. CANCELLED AFTER DISPATCH -> SLOT HELD FOR SIX HOURS (P1) The other end of the same narrowing. Cancelling a dispatched job deliberately leaves the slot to the finalize callback, because the executor is still running and still billing. If that executor dies, no callback arrives -- and neither sweep phase selects a CANCELLED row (phase 1 wants PENDING, phase 2 wants DISPATCHED/RUNNING), so the slot sat occupied until Redis expired it. New sweep phase 3, bounded on BOTH sides: older than the stuck grace, so a live executor is not cut short, and newer than the slot TTL, past which Redis has already dropped the entry and there is nothing left to release. Without that second bound it would rescan every cancelled job ever, forever. 3. A FAILED CANCELLATION WEBHOOK WAS ACKNOWLEDGED (P1) `agent_kv_cancelled` ignored `send_webhook`'s return, so a non-2xx or a connection failure was reported as success, the queue message acknowledged, and the caller simply never received the terminal notification the task exists to deliver. `_send_webhook` now returns the result and the task raises on a failed delivery, under the same retry budget `agent_kv_error` got. 4. THE ORPHAN CLEANUP DID NOT CHECK WHETHER IT WORKED (P1) The 2.8 fix called `delete_job_files` and ignored the result. That helper reports which refs are confirmed gone and swallows the rest -- normally the ref survives as the retry handle, but here there is no row to hold it. A failed delete therefore still orphaned the object, silently. Now checks, and logs the ref itself at ERROR: it is the only way anyone can clean it up by hand. 5. A NUMERIC `stage` RETURNED 500 (P2) A truthy non-string passed the presence check and then raised TypeError at `len(stage)` -- my own bounds check from 2.13, turning a malformed report into a server error. Now a 400. 6. CANCELLATION WEBHOOK CAN REPEAT ON REDELIVERY (P2) Acknowledged, not fixed here: it is inherent to at-least-once delivery and the finalize webhook has the same property. Deduplicating needs delivery state on the job row, which is the design question raised under 2.10 and not something to decide inside this commit. Test-mock changes worth flagging: `test_dispatch.py`'s positional `call_args_list[N]` assertions broke on every added query, so they are now content-based (`_filtered_with`). The two bookkeeping-failure tests had a blanket `filter.side_effect` that now hits the new terminal check -- a different, earlier failure -- so the failure is scoped to the bookkeeping call and their original meaning is preserved. 232 backend tests (from 228), ide_callback 20. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/dispatch.py | 18 ++++ backend/agent_kv/execution_views.py | 24 ++++- backend/agent_kv/internal_views.py | 4 + backend/agent_kv/maintenance.py | 37 +++++++- backend/agent_kv/rate_limiter.py | 6 +- backend/agent_kv/tests/test_dispatch.py | 114 +++++++++++++++++++++--- backend/agent_kv/tests/test_sweeps.py | 67 +++++++++++++- workers/ide_callback/agent_kv_tasks.py | 30 ++++++- 8 files changed, 274 insertions(+), 26 deletions(-) diff --git a/backend/agent_kv/dispatch.py b/backend/agent_kv/dispatch.py index d2d8348686..de29cf50f6 100644 --- a/backend/agent_kv/dispatch.py +++ b/backend/agent_kv/dispatch.py @@ -125,6 +125,24 @@ def dispatch_job(job, *, extractor: str, schema: dict, options: dict) -> None: "pages_total": job.pages_total, }, ) + # Last check before spending money. A cancel can land between the + # submit's `job.save()` and this enqueue: the cancel sees a PENDING, + # never-dispatched row, so it terminalizes it AND releases its + # concurrency slot -- correctly, because nothing had been dispatched + # yet. Enqueueing anyway would then run paid work for a job the caller + # already cancelled, with its slot already handed to someone else. + # + # Re-read rather than trusting the in-memory row, which predates the + # cancel by construction. + if AgentKVJob.objects.filter( + id=job.id, status__in=list(AgentKVJob.TERMINAL) + ).exists(): + logger.info( + "agent-kv: job %s was terminalized before dispatch; not " "enqueueing", + job.id, + ) + return + cb_kwargs = {"callback_kwargs": {"job_id": str(job.id), "org_id": org_id}} _dispatcher().dispatch_with_callback( context, diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index d9fe362fc0..eb3bcb9645 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -288,14 +288,30 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): # unreachable by every cleanup path there is. It would sit in the # bucket forever, holding customer data nobody can find or delete. if job.input_ref: + # `delete_job_files` reports which refs are confirmed gone and + # swallows the rest -- normally the ref survives as the retry + # handle. Here there is no row to hold it, so a delete that + # failed means the object is orphaned with nothing anywhere + # pointing at it. Log the ref itself: it is the only way anyone + # can clean it up by hand. try: - delete_job_files(job) + cleared = delete_job_files(job) except Exception: + cleared = [] logger.exception( - "agent-kv: could not remove the staged input for failed " - "submit %s; it is now orphaned (no job row carries its " - "ref, so TTL cleanup cannot reach it)", + "agent-kv: staged-input cleanup raised for failed " + "submit %s (ref=%s)", job.id, + job.input_ref, + ) + if "input_ref" not in cleared: + logger.error( + "agent-kv: the staged input for failed submit %s could " + "NOT be removed and no job row exists to carry its ref " + "-- object %s is orphaned and unreachable by TTL " + "cleanup; remove it manually", + job.id, + job.input_ref, ) return _fail_job_response( job, diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index 6d8206b4ad..de4583f5ff 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -131,6 +131,10 @@ def post(self, request, *args, job_id=None, **kwargs): # `job.stage` is varchar(32). A longer name was an unhandled 500, and # `job.stages` could grow unbounded distinct keys from a misbehaving # or malicious executor. + if not isinstance(stage, str): + # A truthy non-string (e.g. `1`) passed the presence check above and + # then raised TypeError at `len()` -- a 500 for a malformed report. + return Response({"detail": "stage must be a string"}, status=400) if len(stage) > 32: return Response({"detail": "stage name too long"}, status=400) diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index 6ea8845a6a..c391761ffe 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -28,7 +28,7 @@ from django.utils import timezone from agent_kv.models import AgentKVJob, JobStatus -from agent_kv.rate_limiter import AgentKVConcurrencyLimiter +from agent_kv.rate_limiter import SLOT_TTL_SECONDS, AgentKVConcurrencyLimiter from agent_kv.storage import delete_job_files logger = logging.getLogger(__name__) @@ -162,6 +162,41 @@ def run_sweep() -> dict: timed_out += 1 AgentKVConcurrencyLimiter.release(str(org_id), str(job.id)) + # Phase 3: release slots still held by jobs that were CANCELLED after being + # dispatched. + # + # Cancelling a dispatched job deliberately does NOT release its slot -- the + # executor is still running and still billing, so the slot belongs to the + # finalize callback that will arrive when it finishes. But if that executor + # dies, no callback ever arrives, and neither phase above selects a + # CANCELLED row: phase 1 wants PENDING, phase 2 wants DISPATCHED/RUNNING. + # The slot then sits occupied until Redis expires it. + # + # Bounded on BOTH sides, which is what keeps this from rescanning the same + # ancient rows forever: older than the stuck grace (so a live executor is + # not cut short) and newer than the slot TTL (past which Redis has already + # dropped the entry, so there is nothing left to release). + slot_ttl_floor = now - timedelta(seconds=SLOT_TTL_SECONDS) + abandoned_cancelled = AgentKVJob.objects.filter( + status=JobStatus.CANCELLED, + dispatched_at__isnull=False, + completed_at__lt=stuck_cutoff, + completed_at__gt=slot_ttl_floor, + ).order_by("completed_at")[:_MAINTENANCE_BATCH_LIMIT] + + released = 0 + for job in abandoned_cancelled: + # `release` is an idempotent zrem, so re-releasing a slot the callback + # already freed costs nothing and is not worth a guard. + AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) + released += 1 + if released: + logger.info( + "agent-kv sweep released %s concurrency slot(s) held by cancelled " + "jobs whose executor never called back", + released, + ) + # These two counts are the only evidence the sweep ran and the only # evidence it had to do anything. A sweep that terminalizes a thousand jobs # as FAILED used to emit nothing at all -- the module has had no logger diff --git a/backend/agent_kv/rate_limiter.py b/backend/agent_kv/rate_limiter.py index 85796ef5d2..0102e2c1f7 100644 --- a/backend/agent_kv/rate_limiter.py +++ b/backend/agent_kv/rate_limiter.py @@ -26,7 +26,11 @@ def _limiter_failure_allows_request() -> bool: return bool(getattr(settings, "AGENT_KV_LIMITER_FAIL_OPEN", False)) -_SLOT_TTL_SECONDS = 6 * 3600 +#: How long a held slot survives in Redis without being released. Public +#: because the sweep needs it: past this age there is nothing left to release, +#: which is what bounds its cancelled-job scan. +SLOT_TTL_SECONDS = 6 * 3600 +_SLOT_TTL_SECONDS = SLOT_TTL_SECONDS def _redis(): diff --git a/backend/agent_kv/tests/test_dispatch.py b/backend/agent_kv/tests/test_dispatch.py index bfcbeea953..c63854ae6c 100644 --- a/backend/agent_kv/tests/test_dispatch.py +++ b/backend/agent_kv/tests/test_dispatch.py @@ -17,6 +17,43 @@ from agent_kv.models import AgentKVJob, JobStatus # noqa: E402 +def _fail_only_bookkeeping(m_objects, exc): + """Let the pre-dispatch terminal check succeed, then fail the next query. + + These tests are about a DB error during POST-ENQUEUE bookkeeping, which + must not be reported as a failed dispatch. A blanket `filter.side_effect` + now hits the pre-dispatch terminal check instead -- a different, earlier + failure that correctly DOES abort the dispatch, since a job that cannot be + verified as live must not have money spent on it. + """ + live = mock.MagicMock() + live.exists.return_value = False + m_objects.filter.side_effect = [live, exc] + + +def _filtered_with(m_objects, **expected): + """True if `objects.filter` was ever called with exactly these kwargs. + + Content-based rather than positional: `dispatch_job` makes several filter + calls (the pre-dispatch terminal check, the guarded bookkeeping UPDATE, and + on the slow path a narrowed fallback), and index-pinned assertions broke + every time one was added. + """ + return any(call.kwargs == expected for call in m_objects.filter.call_args_list) + + +def _not_cancelled(m_objects): + """Make the pre-dispatch terminal check report "still live". + + `dispatch_job` re-reads the row immediately before enqueueing, so a cancel + that landed between the submit's save() and the enqueue cannot result in + paid work for a job whose slot was already released. With `objects` mocked + the default `.exists()` is a truthy Mock, which would short-circuit every + dispatch in this file. + """ + m_objects.filter.return_value.exists.return_value = False + + def _job(): from account_v2.models import Organization @@ -34,6 +71,7 @@ def _job(): @mock.patch.object(dispatch, "_dispatcher") @mock.patch.object(AgentKVJob, "objects") def test_dispatch_success_stamps_job(m_objects, m_disp, m_key): + _not_cancelled(m_objects) job = _job() dispatch.dispatch_job( job, @@ -74,7 +112,9 @@ def test_dispatch_success_stamps_job(m_objects, m_disp, m_key): assert job.dispatched_at is not None # Guarded queryset UPDATE (not job.save()): only a still-PENDING row may # be advanced to DISPATCHED. - m_objects.filter.assert_called_once_with(id=job.id, status=JobStatus.PENDING) + # The pre-dispatch terminal check filters first; the guarded bookkeeping + # UPDATE is the last filter call. + assert _filtered_with(m_objects, id=job.id, status=JobStatus.PENDING) m_objects.filter.return_value.update.assert_called_once_with( task_id=job.task_id, status=JobStatus.DISPATCHED, @@ -97,6 +137,7 @@ def test_dispatch_guarded_update_cannot_overwrite_a_terminal_row( pinning the exact WHERE-clause kwargs and simulating the "no rows matched" outcome, since this suite runs with no real DB. """ + _not_cancelled(m_objects) m_objects.filter.return_value.update.return_value = 0 # simulates a FAILED row job = _job() @@ -104,10 +145,7 @@ def test_dispatch_guarded_update_cannot_overwrite_a_terminal_row( # the update's row count; it already told the caller it dispatched. dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) - assert m_objects.filter.call_args_list[0].kwargs == { - "id": job.id, - "status": JobStatus.PENDING, - } + assert _filtered_with(m_objects, id=job.id, status=JobStatus.PENDING) # The 0-row result now triggers a SECOND guarded write (below), whose # `.exclude(status__in=TERMINAL)` is what keeps a terminal row untouched # here -- so the terminal case is still structurally safe. @@ -131,6 +169,7 @@ def test_dispatch_guarded_update_cannot_overwrite_a_terminal_row( def test_dispatch_stamps_dispatched_at_when_the_row_already_moved_to_running( m_objects, m_disp, m_key ): + _not_cancelled(m_objects) m_objects.filter.return_value.update.return_value = 0 # PENDING guard missed job = _job() @@ -138,10 +177,7 @@ def test_dispatch_stamps_dispatched_at_when_the_row_already_moved_to_running( # Second write is narrowed to a row that still has no dispatch time, and # excludes terminal rows. - assert m_objects.filter.call_args_list[1].kwargs == { - "id": job.id, - "dispatched_at__isnull": True, - } + assert _filtered_with(m_objects, id=job.id, dispatched_at__isnull=True) fallback = m_objects.filter.return_value.exclude.return_value update_kwargs = fallback.update.call_args.kwargs assert update_kwargs["dispatched_at"] == job.dispatched_at @@ -157,12 +193,14 @@ def test_dispatch_stamps_dispatched_at_when_the_row_already_moved_to_running( def test_dispatch_does_not_attempt_the_fallback_when_the_pending_guard_won( m_objects, m_disp, m_key ): + _not_cancelled(m_objects) m_objects.filter.return_value.update.return_value = 1 # normal path job = _job() dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) - assert len(m_objects.filter.call_args_list) == 1 + # Terminal check + the single guarded bookkeeping UPDATE; no fallback. + assert len(m_objects.filter.call_args_list) == 2 assert not m_objects.filter.return_value.exclude.called @@ -187,7 +225,10 @@ def test_dispatch_job_uses_platform_api_key_lookup(m_disp): return_value=mock.Mock(key="the-real-key"), ): job = _job() - with mock.patch.object(AgentKVJob, "objects"): + with mock.patch.object(AgentKVJob, "objects") as m_objects: + # Without this the pre-dispatch terminal check reads a truthy Mock + # and short-circuits, so nothing is ever enqueued. + _not_cancelled(m_objects) dispatch.dispatch_job( job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={} ) @@ -272,7 +313,8 @@ def test_dispatcher_factory_call_matches_the_live_signature(): def test_bookkeeping_failure_does_not_fail_an_already_queued_dispatch( m_objects, m_disp, m_key ): - m_objects.filter.side_effect = OSError("db gone") + _not_cancelled(m_objects) + _fail_only_bookkeeping(m_objects, OSError("db gone")) job = _job() # Must not raise: raising is what would mark the live job FAILED. @@ -289,9 +331,55 @@ def test_bookkeeping_failure_is_logged_at_error_level(m_objects, m_disp, m_key, """Swallowing it silently would trade one bad failure mode for another: the sweep reconciles the row, but nothing would say why it had to. """ - m_objects.filter.side_effect = OSError("db gone") + _not_cancelled(m_objects) + _fail_only_bookkeeping(m_objects, OSError("db gone")) with caplog.at_level("ERROR", logger=dispatch.logger.name): dispatch.dispatch_job( _job(), extractor=TABLE_EXTRACTOR_NAME, schema={}, options={} ) assert any(r.levelname == "ERROR" and r.exc_info for r in caplog.records) + + +# --------------------------------------------------------------------------- +# A job cancelled between submit's save() and the enqueue must not be dispatched. +# +# The cancel sees a PENDING, never-dispatched row, so it terminalizes it AND +# releases its concurrency slot -- correctly, nothing had been dispatched yet. +# Enqueueing anyway would then run paid work for a job the caller already +# cancelled, with its slot already handed to the next submit: the concurrency +# ceiling bypassed and the customer billed for a cancelled job. +# +# Reported by Greptile on PR #2317. +# --------------------------------------------------------------------------- +@mock.patch.object(dispatch, "_platform_api_key", return_value="pk") +@mock.patch.object(dispatch, "_dispatcher") +@mock.patch.object(AgentKVJob, "objects") +def test_a_job_cancelled_before_enqueue_is_not_dispatched(m_objects, m_disp, m_key): + job = _job() + m_objects.filter.return_value.exists.return_value = True # already terminal + + dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) + + assert ( + not m_disp.return_value.dispatch_with_callback.called + ), "paid work was enqueued for a job that was already cancelled" + # And no bookkeeping UPDATE either -- there is nothing to advance. + assert not m_objects.filter.return_value.update.called + + +@mock.patch.object(dispatch, "_platform_api_key", return_value="pk") +@mock.patch.object(dispatch, "_dispatcher") +@mock.patch.object(AgentKVJob, "objects") +def test_the_terminal_check_reads_the_row_rather_than_the_in_memory_copy( + m_objects, m_disp, m_key +): + """The in-memory job predates the cancel by construction, so trusting + `job.status` here would never see it. + """ + job = _job() + job.status = JobStatus.PENDING # stale: the row may already be CANCELLED + _not_cancelled(m_objects) + + dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) + + assert _filtered_with(m_objects, id=job.id, status__in=list(AgentKVJob.TERMINAL)) diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py index 13cb1e28f7..49542aca04 100644 --- a/backend/agent_kv/tests/test_sweeps.py +++ b/backend/agent_kv/tests/test_sweeps.py @@ -63,21 +63,27 @@ def _post(path, body=None): # other phase's identical-shaped chain aliasing it. -def _wire_sweep_phases(m_objects, never_dispatched=(), stuck=()): - """Wire the sweep's two phase queries. +def _wire_sweep_phases(m_objects, never_dispatched=(), stuck=(), cancelled=()): + """Wire the sweep's three phase queries. Phase 2 is `filter(status__in=...).filter(Q(...) | Q(...))` -- two levels, because the age test is an OR: `dispatched_at < cutoff` OR `dispatched_at IS NULL AND created_at < cutoff`. A `__lt` filter alone can never match a NULL (SQL `NULL < x` is not true), so without that second arm a DISPATCHED/RUNNING row with a NULL `dispatched_at` hangs forever. + + Phase 3 releases slots still held by jobs CANCELLED after dispatch, whose + executor died without calling back -- neither phase above selects a + CANCELLED row, so the slot would sit occupied until Redis expired it. """ phase1_qs = mock.MagicMock() phase1_qs.order_by.return_value.__getitem__.return_value = list(never_dispatched) phase2_status_qs = mock.MagicMock() phase2_qs = phase2_status_qs.filter.return_value phase2_qs.order_by.return_value.__getitem__.return_value = list(stuck) - m_objects.filter.side_effect = [phase1_qs, phase2_status_qs] + phase3_qs = mock.MagicMock() + phase3_qs.order_by.return_value.__getitem__.return_value = list(cancelled) + m_objects.filter.side_effect = [phase1_qs, phase2_status_qs, phase3_qs] return phase1_qs, phase2_qs, phase2_status_qs @@ -662,3 +668,58 @@ def test_frozen_sweep_and_ttl_cleanup_urls_resolve_to_the_right_views(): ttl_cleanup = resolve("/internal/v1/agent-kv/ttl-cleanup/") assert ttl_cleanup.func.cls is iv.TTLCleanupView + + +# --------------------------------------------------------------------------- +# Phase 3: slots held by jobs CANCELLED after dispatch. +# +# Cancelling a dispatched job deliberately does not release its slot -- the +# executor is still running and still billing, so the slot belongs to the +# finalize callback. But if that executor dies no callback arrives, and neither +# phase 1 (PENDING) nor phase 2 (DISPATCHED/RUNNING) selects a CANCELLED row, +# so the slot sat occupied until Redis expired it six hours later. +# +# Reported by Greptile on PR #2317. +# --------------------------------------------------------------------------- +@mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +def test_sweep_releases_slots_held_by_abandoned_cancelled_jobs( + m_objects, m_mark_terminal, m_release +): + job = AgentKVJob(status=JobStatus.CANCELLED) + job.organization_id = "org1" + _wire_sweep_phases(m_objects, cancelled=[job]) + + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + m_release.assert_called_once_with("org1", str(job.id)) + # The job is ALREADY terminal; phase 3 only frees the slot. + assert not m_mark_terminal.called + + +@mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "objects") +def test_phase_three_is_bounded_on_both_sides(m_objects, m_release): + """Older than the stuck grace, so a live executor is not cut short; and + newer than the slot TTL, past which Redis has already dropped the entry and + there is nothing left to release. Without the second bound this would + rescan every cancelled job ever, forever. + """ + frozen_now = timezone.now() + _wire_sweep_phases(m_objects) + + with mock.patch.object(timezone, "now", return_value=frozen_now): + iv.SweepView.as_view()(_post("/x")) + + kwargs = m_objects.filter.call_args_list[2].kwargs + assert kwargs["status"] == JobStatus.CANCELLED + assert kwargs["dispatched_at__isnull"] is False + stuck_cutoff = frozen_now - timedelta( + seconds=settings.AGENT_KV_STUCK_JOB_GRACE_SECONDS + ) + assert kwargs["completed_at__lt"] == stuck_cutoff + assert kwargs["completed_at__gt"] == frozen_now - timedelta( + seconds=maintenance.SLOT_TTL_SECONDS + ) diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index 65aba8f73a..ddef2f5b22 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -201,8 +201,17 @@ def agent_kv_error( raise -@worker_task(name="agent_kv_cancelled") +@worker_task( + bind=True, + name="agent_kv_cancelled", + autoretry_for=(Exception,), + max_retries=3, + retry_backoff=True, + retry_backoff_max=300, + retry_jitter=True, +) def agent_kv_cancelled( + self, callback_kwargs: dict[str, Any] | None = None, ) -> dict[str, Any] | None: """Fire the terminal webhook for a job cancelled by the API. @@ -226,14 +235,27 @@ def agent_kv_cancelled( if not url: return None try: - _send_webhook(url, job_id, "cancelled") + delivered = _send_webhook(url, job_id, "cancelled") except Exception: logger.exception("agent_kv_cancelled webhook failed: job_id=%s", job_id) raise + if not delivered: + # `send_webhook` returns False for a non-2xx response or a connection + # failure. Returning success here acknowledged the queue message, so + # the caller simply never received the terminal notification this task + # exists to deliver -- and nothing retried or recorded it. Raise so the + # retry budget applies, exactly as a transport exception would. + logger.error( + "agent_kv_cancelled: webhook not delivered for job %s (retry %s of " "%s)", + job_id, + self.request.retries, + self.max_retries, + ) + raise RuntimeError(f"cancellation webhook not delivered for job {job_id}") return {"job_id": job_id, "status": "cancelled"} -def _send_webhook(url: str, job_id: str, status: str) -> None: +def _send_webhook(url: str, job_id: str, status: str) -> bool: """Deliver one terminal notification. Single place the SSRF waiver is read, so the cancel path cannot drift from @@ -244,7 +266,7 @@ def _send_webhook(url: str, job_id: str, status: str) -> None: allow_insecure = os.environ.get( "AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE", "" ).lower() in ("1", "true", "yes") - send_webhook( + return send_webhook( url, {"job_id": job_id, "status": status}, allow_insecure=allow_insecure, From bd82bc8dd61b37de9f0bb482e67a26e30763d6f4 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 6 Oct 2026 15:39:03 +0530 Subject: [PATCH 11/54] UN-4232 [FIX] Webhook delivery results, and two comments that described the opposite of the code WEBHOOK DELIVERY RESULT (2.10, partial) `send_webhook` returns False for a non-2xx or a connection failure, and both call sites discarded it -- so a terminal notification could fail to land with no retry, no record, and for a non-2xx not even a log line. The cancellation task raises (its retry budget then applies). The finalize path logs at ERROR instead: it runs AFTER finalize has already terminalized the job, so raising would re-run a finalize that is no longer free. Three tests. Durable delivery -- attempt counts and a `webhook_delivered_at` the status document can expose -- is the open design question in 2.10 and is deliberately not decided here. TWO COMMENTS THAT SAID THE OPPOSITE OF WHAT THE CODE DOES `constants.py` line 1 -- the first line of the file that defines the routing table -- said "v1 accepts exactly one extractor (`kv`), so the job row does not carry which one it ran". Both halves are now false: `kv` is the one extractor this deployment does NOT accept, and `AgentKVJob.extractor` has carried the name since migration 0002. `test_queue_consumer_wiring.py`'s docstring described only "every queue we dispatch to must have a consumer" and then narrated how `celery_executor_agentic_kv` ought to be wired in -- while the file's actual assertion is that it must NOT be advertised. That is the one thing a guard with an inverted assertion must not say: a reader checking the file against its own description would have concluded the test was wrong. It now states both directions and why each exists. Both were mine, and both are the kind of stale comment that is worse than no comment -- a reader trusts them over the code. 232 backend tests, ide_callback 23, queue wiring 12. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/constants.py | 11 +++- workers/ide_callback/agent_kv_tasks.py | 18 +++++- .../tests/test_agent_kv_callbacks.py | 63 +++++++++++++++++++ workers/tests/test_queue_consumer_wiring.py | 32 ++++++---- 4 files changed, 108 insertions(+), 16 deletions(-) diff --git a/backend/agent_kv/constants.py b/backend/agent_kv/constants.py index 431316e078..04cf856e04 100644 --- a/backend/agent_kv/constants.py +++ b/backend/agent_kv/constants.py @@ -1,6 +1,11 @@ -#: v1 accepts exactly one extractor (`kv`), so the job row does not carry which -#: one it ran. When fan-out lands this becomes per-job state rather than a -#: constant -- the wire format (spec §7.0) is already shaped for that. +#: The `kv` extractor's name. Kept as a constant, but NOT routable on this +#: deployment -- see `EXTRACTOR_ROUTES` below, which carries `table` only. +#: +#: An earlier version of this comment said "v1 accepts exactly one extractor +#: (`kv`), so the job row does not carry which one it ran". Both halves are now +#: wrong, on line 1 of the file that defines the routing table: `kv` is the one +#: extractor this deployment does NOT accept, and `AgentKVJob.extractor` has +#: carried the name since migration 0002. V1_EXTRACTOR_NAME = "kv" STAGE_NAMES = [ diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index ddef2f5b22..4b13142892 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -290,4 +290,20 @@ def _maybe_webhook(finalize_response: dict[str, Any], job_id: str) -> None: url = finalize_response.get("webhook_url") or "" if not url: return - _send_webhook(url, job_id, finalize_response.get("status", "")) + status = finalize_response.get("status", "") + if not _send_webhook(url, job_id, status): + # `send_webhook` returns False for a non-2xx or a connection failure. + # Discarding that meant a terminal notification could fail to land with + # no retry, no record on the job, and -- for a non-2xx -- not even a log + # line. Logged at ERROR here rather than raised: unlike the cancellation + # task, this runs AFTER finalize has already terminalized the job, so + # raising would re-run a finalize that is no longer idempotent-free. + # + # Durable delivery (attempt counts, a `webhook_delivered_at` the status + # document can expose) is the open design question in 2.10. + logger.error( + "agent-kv webhook not delivered for job %s (status=%s); the caller " + "was not notified and nothing will retry", + job_id, + status, + ) diff --git a/workers/ide_callback/tests/test_agent_kv_callbacks.py b/workers/ide_callback/tests/test_agent_kv_callbacks.py index 07ad5bf24b..6278b48d82 100644 --- a/workers/ide_callback/tests/test_agent_kv_callbacks.py +++ b/workers/ide_callback/tests/test_agent_kv_callbacks.py @@ -430,3 +430,66 @@ def test_webhook_fires_from_error_link_too(self, mock_send_webhook, cb_kwargs): {"job_id": "job-1", "status": "failed"}, allow_insecure=False, ) + + +class TestWebhookDeliveryResult: + """A webhook that does not land must not pass silently. + + `send_webhook` returns False for a non-2xx response or a connection + failure, and both call sites used to discard it. The cancellation task now + raises (so its retry budget applies); the finalize path logs, because it + runs after the job is already terminalized and re-running finalize is not + free. Reported as 2.10 in the branch review and by Greptile on #2317. + """ + + @patch(_PATCH_SEND_WEBHOOK, return_value=False) + def test_a_failed_finalize_webhook_is_logged(self, mock_send, caplog): + import logging + + from ide_callback import agent_kv_tasks as akt + + with caplog.at_level(logging.ERROR, logger="ide_callback.agent_kv_tasks"): + akt._maybe_webhook( + { + "finalized": True, + "webhook_url": "https://hook.example/x", + "status": "completed", + }, + "job-1", + ) + + assert "not delivered" in caplog.text + assert "job-1" in caplog.text + + @patch(_PATCH_SEND_WEBHOOK, return_value=True) + def test_a_delivered_finalize_webhook_is_quiet(self, mock_send, caplog): + import logging + + from ide_callback import agent_kv_tasks as akt + + with caplog.at_level(logging.ERROR, logger="ide_callback.agent_kv_tasks"): + akt._maybe_webhook( + { + "finalized": True, + "webhook_url": "https://hook.example/x", + "status": "completed", + }, + "job-1", + ) + + assert caplog.records == [] + + @patch(_PATCH_SEND_WEBHOOK, return_value=False) + def test_a_failed_cancellation_webhook_raises_so_it_retries(self, mock_send): + from ide_callback.agent_kv_tasks import agent_kv_cancelled + + with pytest.raises(Exception, match="not delivered"): + agent_kv_cancelled.apply( + kwargs={ + "callback_kwargs": { + "job_id": "job-1", + "webhook_url": "https://hook.example/x", + } + }, + throw=True, + ) diff --git a/workers/tests/test_queue_consumer_wiring.py b/workers/tests/test_queue_consumer_wiring.py index 0868d6f35c..6f98c2675f 100644 --- a/workers/tests/test_queue_consumer_wiring.py +++ b/workers/tests/test_queue_consumer_wiring.py @@ -1,20 +1,28 @@ -"""Guard: every queue we dispatch to must have a consumer in the deployed fleet. +"""Guard: queues and routes must match the fleet this deployment actually runs. -This is the OSS analogue of the cloud chart's ``validate-pg-worker-fleet.yaml``. -It exists because the failure it catches is **silent**: since UN-4046 made the -PG transport unconditional, a queue whose consumer is not configured still +The OSS analogue of the cloud chart's ``validate-pg-worker-fleet.yaml``, and it +exists because the failure it catches is **silent**: since UN-4046 made the PG +transport unconditional, a queue whose consumer is not configured still *accepts* work -- ``enqueue_task`` succeeds, rows land in ``pg_queue_message``, and nothing errors at the producer. The job simply sits in DISPATCHED forever. -That is exactly what happened to ``celery_executor_agentic_kv``: the Agent-KV -branch was written when the Celery executor was live, so its queue was wired -into ``CELERY_QUEUES_EXECUTOR`` -- a variable read only by -``workers/executor/worker.py`` (the now-disabled Celery worker). The live -``pg-queue-consumer`` reads ``WORKER_PG_QUEUE_CONSUMER_QUEUE``, which did not -list it. +The assertions run in BOTH directions, which is the part to read carefully: -Each test therefore asserts the queue is present in the variable the *running* -consumer actually reads, at every site that configures one. +* ``celery_executor_agentic_table`` and ``agent_kv_callback`` must HAVE a + consumer -- they are what this deployment dispatches onto. +* ``celery_executor_agentic_kv`` must NOT be advertised. This deployment ships + no ``agentic_kv`` plugin, so a fleet listing that queue would accept work + nothing can drain -- the same silent failure, arrived at from the other side. + +An earlier version of this docstring described only the first direction and +narrated how ``celery_executor_agentic_kv`` ought to be wired in. That is the +one thing a guard with an inverted assertion must not say: a reader checking +the file against its own description would have concluded the test was wrong. + +Each test asserts against the variable the *running* consumer actually reads, +at every site that configures one -- plus the traefik rules, where the same +"configured-looking but unreachable" failure shows up as a request served by +the SPA instead of Django. """ from __future__ import annotations From 7efbc4c6b11f9cd02f4f931cc5d43619ebe72f6c Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 7 Oct 2026 10:33:28 +0530 Subject: [PATCH 12/54] UN-4232 [FIX] Address the remaining open review threads on #2317 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine open PR threads, in four groups. **Silent-skip bugs that reported success (2.19)** `constraints.py` called itself fail-closed in its module docstring and on the `except Exception` line, but `evaluate_constraints` records only `is False` as a violation -- so a constraint that raised was reported as "no violation", a positive assurance that nothing was checked. The path is reachable, not theoretical: `_BIN` has `truediv` and `_ALLOWED_NODES` has `ast.Div`, so a row with `quantity == 0` raises `ZeroDivisionError`. Two senses of "fail-closed" were conflated: the grammar IS closed (nothing outside the allowlist runs), the outcome is not. Relabelled, and every drop is now logged -- `exception` level for a raise, `debug` for the ordinary missing-operand skip, which is normal on a document with optional keys. `_aggregate` returned a PARTIAL sum/avg when some cells failed `coerce_number`, which is then compared against a scalar key covering the whole column and reports a violation on a CORRECT document. One blank cell in a 40-row invoice was enough. sum/avg now require every row to contribute; min/max/count are left total over a subset, which is what they mean. **Schemas accepted that could never validate (2.18)** `format: "Number"` is not a known format, so it became a free-text LLM hint, so `validate_format` returned True for every value forever and `/validate` reported `{"valid": true}` -- validation configured, none delivered. `format: "enum:"` and `format: "regex:"` are worse: they do not disable validation, they invert it, and no value can ever conform. An array `_key` naming a non-existent column silently falls back to positional identity, costing accuracy on exactly the arrays the author cared enough to key. All four are refused at `compile_schema` now, with the intended spelling in the message. Free-text format stays accepted -- that is deliberate. `KeySpec` and `ArraySpec` are frozen: they are compile output, the one derived variant already goes through `dataclasses.replace`. **Docs and docstrings that documented the opposite of the code** - docs §12 described both limiters as failing OPEN on Redis errors. They fail CLOSED, and have for the life of this branch. An operator sizing a Redis outage from that row would expect "requests get through" and get "every submit 429s" -- the opposite incident. Corrected, `AGENT_KV_LIMITER_FAIL_OPEN` documented with its real default, and added to both sample.env files. - `internal_client.agent_kv_stage_report`'s examples (`"extract"`, `"started"`, `"completed"`) are all rejected: `StageReportView` accepts only `running`/`done`, and an off-list stage name is stored then filtered out of every status response by `_status_document`. - `ValidateView` had no docstring. A reader of `execution_views.py` sees a complete, decorated, live-looking endpoint; the only thing that decided otherwise was `execution_urls.py`, a file they may never open. - The schema package's pyproject still called itself the "single source of truth for API validation and the extraction engine", which `compile.py:7-15` explicitly retracts. **Latent (2.16)** `mark_terminal`'s guarded UPDATE excludes terminal ROWS, not non-terminal ARGUMENTS, so `mark_terminal(..., RUNNING)` would stamp `completed_at=now()`: a row that reads as finished to the TTL filter and the sweep's cancelled-job phase while staying invisible to the terminal guard, so nothing could ever terminalize it again. No caller does this; the method is named for the invariant, so it enforces it. **Verification** 248 backend tests (was 233) and 128 schema-package tests, all green. Every new guard mutation-tested: reintroducing each bug fails the test that covers it -- the format/`_key` checks (14 tests), the partial-sum skip (3), the drop logging (1), the freeze (1), the `mark_terminal` guard (3), the ValidateView docstring (1) and the two docs rows (2). `unstract/agent-kv-schema/tests/test_constraints.py` was 0 bytes against a 258-line module -- an empty file with that name reads as coverage. It now carries the operator, arithmetic, boolean and aggregate matrix, plus a test that `compile._ALLOWED_NODES`/`_ALLOWED_CALLS` and `constraints._CMP`/`_BIN`/`_AGG` agree: two hand-maintained allowlists in two files, where a node accepted at submit but absent from the evaluator is a constraint silently skipped for every document (2.20). Not done here, and why: surfacing a skipped-with-reason third state in the QA output is a public contract change on the `kv` extractor, which this deployment does not ship. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 21 ++ backend/agent_kv/maintenance.py | 15 +- backend/agent_kv/models.py | 14 + backend/agent_kv/tests/test_job_views.py | 37 +++ backend/agent_kv/tests/test_models.py | 42 +++ backend/agent_kv/tests/test_rate_limiter.py | 37 +++ backend/agent_kv/tests/test_submit_view.py | 64 +++-- backend/agent_kv/tests/test_sweeps.py | 26 ++ .../tests/test_table_extractor_routing.py | 35 +++ backend/sample.env | 1 + docker/sample.env | 1 + docs/agent-kv-api.md | 5 +- unstract/agent-kv-schema/pyproject.toml | 2 +- .../src/unstract/agent_kv_schema/compile.py | 81 +++++- .../unstract/agent_kv_schema/constraints.py | 69 ++++- .../unstract/agent_kv_schema/dataclasses.py | 12 +- .../agent-kv-schema/tests/test_compile.py | 144 ++++++++++ .../agent-kv-schema/tests/test_constraints.py | 258 ++++++++++++++++++ workers/shared/api/internal_client.py | 13 +- 19 files changed, 840 insertions(+), 37 deletions(-) diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index eb3bcb9645..262888fe0c 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -353,6 +353,27 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): class ValidateView(APIView): + """Dry-run schema compile. **NOT ROUTED ON THIS DEPLOYMENT.** + + ``execution_urls.py`` deliberately omits this view, and that file carries + the full rationale. The short version: it compiles a `kv` keys schema and + does nothing else, and `kv` is the one extractor this build refuses + (``EXTRACTOR_ROUTES`` carries `table` only), so publishing it would + advertise validation for an extractor every submit 400s. The `table` + extractor's ``keys`` is ``{"target_table": ...}``, validated by + ``TableKeysSerializer`` at submit instead. + + Kept in the tree, not deleted: restoring the endpoint is one line in + ``execution_urls.py``, and deleting it would force a content merge in the + file the branch carrying the KV engine rewrites most. + + So read the code below as dormant. Nothing reaches it but the unit tests + that keep it honest -- in particular, it is NOT a live surface for the + rate limiter or the key validator it still decorates, and a reader looking + for the deployment's public endpoints should look at ``execution_urls.py``, + which is the only place that decides. + """ + authentication_classes: list = [] permission_classes: list = [] diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index c391761ffe..a0a6bbe545 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -182,7 +182,20 @@ def run_sweep() -> dict: dispatched_at__isnull=False, completed_at__lt=stuck_cutoff, completed_at__gt=slot_ttl_floor, - ).order_by("completed_at")[:_MAINTENANCE_BATCH_LIMIT] + # NEWEST first, the opposite of the other two phases and deliberate. + # Releasing a slot does not remove its row from this query -- there is + # no "released" marker -- so oldest-first would re-select the same 500 + # rows every sweep whenever the backlog exceeds the batch limit, and + # rows arriving behind them would age out of the window never having + # been looked at. + # + # Newest-first inverts which rows lose: the ones skipped are the oldest, + # i.e. closest to the slot TTL floor, where Redis is about to drop the + # entry anyway and a release buys almost nothing. Every row is seen + # while its release still matters, unless more than + # `_MAINTENANCE_BATCH_LIMIT` become eligible inside one sweep interval + # -- and the TTL remains the backstop for that. + ).order_by("-completed_at")[:_MAINTENANCE_BATCH_LIMIT] released = 0 for job in abandoned_cancelled: diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index fa28b4d68e..7afb051364 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -126,7 +126,21 @@ def mark_terminal( Guarded UPDATE: at-least-once callbacks, cancel, and the sweep can all race; whoever lands first wins and everyone else no-ops. + + The `.exclude(status__in=TERMINAL)` below guards the ROW, not the + argument -- so without the check that opens this method, + `mark_terminal(..., JobStatus.RUNNING)` would stamp + `status=RUNNING, completed_at=now()`: a row that reads as finished to + every TTL/sweep query that keys off `completed_at`, is invisible to + the terminal guard, and can never be terminalized again by anything + that trusts `completed_at`. No caller does this today; the method is + named for the invariant, so it enforces it rather than documenting it. """ + if new_status not in cls.TERMINAL: + raise ValueError( + f"mark_terminal called with non-terminal status {new_status!r}; " + f"expected one of {sorted(cls.TERMINAL)}" + ) fields = {"status": new_status, "completed_at": timezone.now()} if error: fields["error"] = error diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index b2ff721645..a5332748b3 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -4,6 +4,7 @@ from unittest import mock import django +import pytest from django.apps import apps os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") @@ -37,6 +38,42 @@ def test_foreign_org_job_is_404(m_keys, m_jobs): assert resp.status_code == 404 +# --------------------------------------------------------------------------- +# (1b) ...and the lookup is actually ORG-SCOPED. +# +# The test above forces `DoesNotExist` and asserts the view 404s, which says +# nothing about the `organization_id` filter: delete +# `organization_id=agent_kv_key.organization_id` from `_get_job` and it stays +# green -- while every org could then read, cancel, delete and fetch the result +# of every other org's job. +# +# The production code IS correctly scoped. What was missing is a test that can +# detect its removal, on the one security-relevant path in the app. Every +# job-scoped view funnels through `_get_job`, so each is covered. +# +# Reported by Greptile on PR #2317. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("view", "method"), + [ + (ev.JobStatusView, "get"), + (ev.JobResultView, "get"), + (ev.JobCancelView, "post"), + (ev.JobDeleteView, "delete"), + ], +) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_every_job_scoped_view_filters_by_organization(m_keys, m_jobs, view, method): + m_keys.get.return_value = kv_key(org_pk=7) + m_jobs.get.side_effect = AgentKVJob.DoesNotExist + job_id = uuid.uuid4() + + view.as_view()(_authed(method=method), job_id=job_id) + + m_jobs.get.assert_called_once_with(id=job_id, organization_id=7) + + # --------------------------------------------------------------------------- # (2) status running -> stages list ordered per STAGE_NAMES, lowercased # status, only the stages actually present are included. diff --git a/backend/agent_kv/tests/test_models.py b/backend/agent_kv/tests/test_models.py index 6966fac515..231252c135 100644 --- a/backend/agent_kv/tests/test_models.py +++ b/backend/agent_kv/tests/test_models.py @@ -5,6 +5,7 @@ from unittest import mock import django +import pytest from django.apps import apps os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") @@ -52,3 +53,44 @@ def test_mark_terminal_on_already_terminal_row_is_noop_false(m_objects): new_status=JobStatus.COMPLETED, ) assert ok is False + + +# -------------------------------------------------------------------------- +# 2.16: the guarded UPDATE excludes terminal ROWS, not non-terminal ARGUMENTS. +# -------------------------------------------------------------------------- + + +@mock.patch.object(AgentKVJob, "objects") +@pytest.mark.parametrize( + "bad_status", [JobStatus.PENDING, JobStatus.DISPATCHED, JobStatus.RUNNING] +) +def test_mark_terminal_refuses_a_non_terminal_status(m_objects, bad_status): + """`mark_terminal(..., RUNNING)` used to stamp `completed_at=now()`. + + That leaves a row that reads as finished to everything keying off + `completed_at` (the TTL filter, the sweep's cancelled-job phase) while + still being invisible to the terminal guard -- so nothing can ever + terminalize it again. No caller does this today; the method is named for + the invariant, so it enforces it. + """ + with pytest.raises(ValueError, match="non-terminal status"): + AgentKVJob.mark_terminal( + job_id=uuid.uuid4(), + organization_id="org1", + new_status=bad_status, + ) + assert not m_objects.filter.called, "no write may be attempted" + + +@mock.patch.object(AgentKVJob, "objects") +@pytest.mark.parametrize("good_status", sorted(AgentKVJob.TERMINAL)) +def test_mark_terminal_accepts_every_terminal_status(m_objects, good_status): + m_objects.filter.return_value.exclude.return_value.update.return_value = 1 + assert ( + AgentKVJob.mark_terminal( + job_id=uuid.uuid4(), + organization_id="org1", + new_status=good_status, + ) + is True + ) diff --git a/backend/agent_kv/tests/test_rate_limiter.py b/backend/agent_kv/tests/test_rate_limiter.py index 58fa295fd0..d7d4cdd9a1 100644 --- a/backend/agent_kv/tests/test_rate_limiter.py +++ b/backend/agent_kv/tests/test_rate_limiter.py @@ -1,7 +1,9 @@ import os +import pathlib from unittest import mock import django +import pytest from django.apps import apps os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") @@ -153,3 +155,38 @@ def test_release_still_tolerates_an_unreachable_backend(m_redis): work, and a slot it cannot free expires on its own TTL. """ rl.AgentKVConcurrencyLimiter.release("org1", "job1") + + +# --------------------------------------------------------------------------- +# The published env table said both limiters "fail open on Redis errors", for +# the whole life of the branch that made them fail CLOSED. An operator reading +# it would size a Redis outage as "requests get through" when the real +# behaviour is "every submit 429s" -- the opposite incident. Docs are the +# contract for a public API, so this is pinned rather than trusted. +# --------------------------------------------------------------------------- + +_DOCS = pathlib.Path(__file__).resolve().parents[3] / "docs" / "agent-kv-api.md" + + +@pytest.mark.parametrize( + "variable", + ["AGENT_KV_CONCURRENT_LIMIT", "AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE"], +) +def test_the_docs_do_not_describe_either_limiter_as_failing_open(variable): + row = next(line for line in _DOCS.read_text().splitlines() if f"`{variable}`" in line) + assert "fails open" not in row.casefold(), row + assert "closed" in row.casefold(), ( + f"the {variable} row must say what a Redis outage does, and it fails " + "closed -- see _limiter_failure_allows_request" + ) + + +def test_the_waiver_flag_is_documented_with_its_real_default(): + text = _DOCS.read_text() + assert "`AGENT_KV_LIMITER_FAIL_OPEN`" in text, ( + "the only way to restore fail-open is undocumented, so the only " + "documented behaviour was the wrong one" + ) + assert not rl._limiter_failure_allows_request() or getattr( + settings, "AGENT_KV_LIMITER_FAIL_OPEN", False + ), "the default must be fail-closed" diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py index ba105f32dd..b9d7ba70ba 100644 --- a/backend/agent_kv/tests/test_submit_view.py +++ b/backend/agent_kv/tests/test_submit_view.py @@ -58,20 +58,26 @@ def _authed_post(data=None): def _valid_validated_data(**overrides): """`SubmitSerializer.validated_data` in the extractor-scoped shape (§7.0).""" - keys = overrides.pop("keys", {"total": {"description": "Grand total"}}) + # `table`, not `kv`. The carve-out re-pointed the real-serializer tests and + # missed this mocked payload, so every view test driven by it called + # `dispatch_job(extractor="kv")` -- whose FIRST statement is + # `EXTRACTOR_ROUTES["kv"]`, a KeyError raised OUTSIDE the try. That landed + # in SubmitView's belt-and-braces `except Exception` and produced the exact + # 500 some of these tests assert, so they passed while never reaching the + # code they name. It also meant the whole file exercised the view with an + # `extractors` payload the real serializer rejects with a 400. + keys = overrides.pop("keys", {"target_table": "Rent roll"}) options = { - "qa": True, - "challenge": True, - "extraction_mode": "whole-doc", - "structured_output": False, - "calculations": "", - "document_class": "", - "key_notes": "", + "instructions": "", + "json_structure": "", + "enable_header_mapping": False, + "correct_number_separators": False, + "number_format": "US", } options.update({k: overrides.pop(k) for k in list(overrides) if k not in _JOB_LEVEL}) data = { "file": mock.Mock(name="uploaded_file"), - "extractors": [{"name": "kv", "keys": keys, "options": options}], + "extractors": [{"name": "table", "keys": keys, "options": options}], "page_start": 1, "page_end": None, "timeout": 0, @@ -265,19 +271,15 @@ def test_dispatch_job_called_with_expected_options_and_schema( m_dispatch, ): m_keys.get.return_value = kv_key() - keys_schema = {"total": {"description": "Grand total"}} + keys_schema = {"target_table": "Rent roll"} _mock_serializer( m_serializer_cls, keys=keys_schema, - qa=False, - challenge=False, - extraction_mode="per-page", - structured_output=True, + number_format="EU", + enable_header_mapping=True, + instructions="skip the header row", page_start=2, page_end=5, - document_class="invoice", - key_notes="ignore footers", - calculations="annualize rent", ) m_limiter.check_and_acquire.return_value = True m_save.side_effect = _stamp_created_at @@ -286,17 +288,19 @@ def test_dispatch_job_called_with_expected_options_and_schema( assert m_dispatch.called kwargs = m_dispatch.call_args.kwargs + # The extractor name itself was never asserted, so this test could not tell + # a `table` dispatch from a `kv` one -- which is how the mocked payload + # stayed on `kv` right through the carve-out. + assert kwargs["extractor"] == "table" assert kwargs["schema"] == keys_schema assert kwargs["options"] == { - "qa": False, - "challenge": False, - "extraction_mode": "per-page", - "structured_output": True, + "instructions": "skip the header row", + "json_structure": "", + "enable_header_mapping": True, + "correct_number_separators": False, + "number_format": "EU", "page_start": 2, "page_end": 5, - "document_class": "invoice", - "key_notes": "ignore footers", - "calculations": "annualize rent", } @@ -425,6 +429,18 @@ def test_platform_key_lookup_failure_inside_real_dispatch_is_caught_end_to_end( resp = ev.SubmitView.as_view()(_authed_post()) + # The assertion this test was missing. Without it it passed while never + # reaching the code it names: the mocked payload said `kv`, so + # `dispatch_job`'s first statement -- `EXTRACTOR_ROUTES["kv"]` -- raised a + # KeyError OUTSIDE the try, the view's belt-and-braces handler produced the + # same 500, and `_platform_api_key` was never called. The + # "leaked-secret-xyz" check below was vacuous: that string was never + # produced. + assert m_platform_key.called, ( + "the platform-key lookup was never reached, so this test proves " + "nothing about the widened dispatch.py try it exists to cover" + ) + assert resp.status_code == 500 assert "leaked-secret-xyz" not in str(resp.data) assert resp.data["status"] == JobStatus.FAILED.lower() diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py index 49542aca04..cfbee37b7e 100644 --- a/backend/agent_kv/tests/test_sweeps.py +++ b/backend/agent_kv/tests/test_sweeps.py @@ -723,3 +723,29 @@ def test_phase_three_is_bounded_on_both_sides(m_objects, m_release): assert kwargs["completed_at__gt"] == frozen_now - timedelta( seconds=maintenance.SLOT_TTL_SECONDS ) + + +def test_phase_three_takes_the_newest_eligible_rows_first(): + """Releasing a slot does not remove its row from phase 3's query -- there + is no "released" marker -- so oldest-first would re-select the same batch + every sweep under backlog, and rows arriving behind it would age out of the + window unprocessed. + + Newest-first inverts which rows lose: the skipped ones are the oldest, i.e. + closest to the slot TTL floor where Redis is about to drop the entry anyway + and a release buys almost nothing. + + Reported by Greptile on PR #2317. + """ + phase1 = mock.MagicMock() + phase1.order_by.return_value.__getitem__.return_value = [] + phase2_status = mock.MagicMock() + phase2_status.filter.return_value.order_by.return_value.__getitem__.return_value = [] + phase3 = mock.MagicMock() + phase3.order_by.return_value.__getitem__.return_value = [] + + with mock.patch.object(AgentKVJob, "objects") as m_objects: + m_objects.filter.side_effect = [phase1, phase2_status, phase3] + maintenance.run_sweep() + + phase3.order_by.assert_called_once_with("-completed_at") diff --git a/backend/agent_kv/tests/test_table_extractor_routing.py b/backend/agent_kv/tests/test_table_extractor_routing.py index 44c012f5b9..07ded10fcd 100644 --- a/backend/agent_kv/tests/test_table_extractor_routing.py +++ b/backend/agent_kv/tests/test_table_extractor_routing.py @@ -239,3 +239,38 @@ def test_a_job_whose_extractor_has_no_stage_list_reports_no_stages(caplog): assert doc["extractors"]["retired_extractor"]["stages"] == [] assert doc["status"] == job.status.lower() assert "retired_extractor" in caplog.text + + +# --------------------------------------------------------------------------- +# `ValidateView` is part of the carve-out: the class stays in the tree and the +# route does not. Both halves are asserted here, including that the class says +# so itself -- a reader of `execution_views.py` sees a complete, decorated, +# live-looking endpoint, and the only thing that decided otherwise was a file +# they may never open. +# --------------------------------------------------------------------------- + + +def test_validate_is_not_a_public_route_on_this_deployment(): + from agent_kv import execution_urls + + names = {p.name for p in execution_urls.urlpatterns} + assert names == { + "agent_kv_submit", + "agent_kv_status", + "agent_kv_result", + "agent_kv_cancel", + }, ( + "the public route set changed; `/validate` compiles a `kv` schema and " + "`kv` is the one extractor this build refuses, so routing it would " + "advertise validation for an extractor every submit 400s" + ) + + +def test_validate_view_documents_that_it_is_unrouted(): + from agent_kv.execution_views import ValidateView + + doc = ValidateView.__doc__ or "" + assert "NOT ROUTED" in doc, ( + "ValidateView is a complete, decorated, live-looking endpoint that " + "nothing reaches. Say so on the class, not only in execution_urls.py" + ) diff --git a/backend/sample.env b/backend/sample.env index c32b815e46..78b526f1d1 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -197,6 +197,7 @@ AGENT_KV_RESULT_TTL_DAYS=7 # Result/input retention win AGENT_KV_MAX_TIMEOUT_SECONDS=300 # Upper bound on the submit `timeout` (sync-wait) field AGENT_KV_CONCURRENT_LIMIT=5 # Per-org concurrent in-flight job cap AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE=60 # Per-key request rate limit (submit + validate) +AGENT_KV_LIMITER_FAIL_OPEN=false # true = both limiters above allow the request when Redis is down (default: refuse with 429) AGENT_KV_SWEEP_GRACE_SECONDS=3600 # Age before a never-dispatched PENDING job is swept AGENT_KV_STUCK_JOB_GRACE_SECONDS=21600 # Age (from dispatch) before a stuck DISPATCHED/RUNNING job is force-failed (6h) AGENT_KV_CALCULATIONS_ENABLED=false # Gates the submit `calculations` field; engine can't run it yet diff --git a/docker/sample.env b/docker/sample.env index 39155d8ba7..de64df726f 100644 --- a/docker/sample.env +++ b/docker/sample.env @@ -132,6 +132,7 @@ AGENT_KV_RESULT_TTL_DAYS=7 # Result/input retention win AGENT_KV_MAX_TIMEOUT_SECONDS=300 # Upper bound on the submit `timeout` (sync-wait) field AGENT_KV_CONCURRENT_LIMIT=5 # Per-org concurrent in-flight job cap AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE=60 # Per-key request rate limit (submit + validate) +AGENT_KV_LIMITER_FAIL_OPEN=false # true = both limiters above allow the request when Redis is down (default: refuse with 429) AGENT_KV_SWEEP_GRACE_SECONDS=3600 # Age before a never-dispatched PENDING job is swept AGENT_KV_STUCK_JOB_GRACE_SECONDS=21600 # Age (from dispatch) before a stuck DISPATCHED/RUNNING job is force-failed (6h) AGENT_KV_CALCULATIONS_ENABLED=false # Gates the submit `calculations` field; engine can't run it yet diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 4d6ebfd424..8cb78e06f6 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -982,8 +982,9 @@ with the default shown: | `AGENT_KV_MAX_SCHEMA_BYTES` | `262144` | Byte cap on the raw `keys` JSON document (256 KiB). | | `AGENT_KV_RESULT_TTL_DAYS` | `7` | Result retention window (and a cancelled job's input, which rides the same TTL — a completed/failed job's input is deleted immediately at finalize instead), stamped at submit time (see [§6](#9-retention-and-ttl)). | | `AGENT_KV_MAX_TIMEOUT_SECONDS` | `300` | Upper bound on the submit `timeout` (synchronous-wait) field. | -| `AGENT_KV_CONCURRENT_LIMIT` | `5` | Per-organization concurrent in-flight job cap (own Redis namespace, fails open on Redis errors). | -| `AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE` | `60` | Per-key request rate limit (submit + validate), fails open on Redis errors. | +| `AGENT_KV_CONCURRENT_LIMIT` | `5` | Per-organization concurrent in-flight job cap (own Redis namespace). Fails **closed** (429) when Redis is unreachable — see `AGENT_KV_LIMITER_FAIL_OPEN`. | +| `AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE` | `60` | Per-key request rate limit (submit + validate). Fails **closed** (429) when Redis is unreachable — see `AGENT_KV_LIMITER_FAIL_OPEN`. | +| `AGENT_KV_LIMITER_FAIL_OPEN` | `false` | What both limiters above do when Redis is unreachable. Default `false` = fail **closed**: the request is refused with a 429. Set `true` to restore the old fail-open behaviour, where an unreachable Redis removed the concurrency ceiling **and** the per-key rate ceiling at once while the API went on accepting billable work — a state that cannot be observed from the outside. A 429 is recoverable by the caller; an unbounded fan-out is not. | | `AGENT_KV_SWEEP_GRACE_SECONDS` | `3600` | Age (from `created_at`) before a never-dispatched `PENDING` job is eligible for the sweep. | | `AGENT_KV_STUCK_JOB_GRACE_SECONDS` | `21600` | Age (from `dispatched_at`) before a `DISPATCHED`/`RUNNING` job is eligible for the sweep's stuck-job phase — force-failed as `"Job timed out"` (6 hours). | | `AGENT_KV_CALCULATIONS_ENABLED` | `false` | Gates the submit `calculations` field; the engine cannot execute it yet, so submit 400s while this is off. | diff --git a/unstract/agent-kv-schema/pyproject.toml b/unstract/agent-kv-schema/pyproject.toml index 9db37d39b7..6723d4478f 100644 --- a/unstract/agent-kv-schema/pyproject.toml +++ b/unstract/agent-kv-schema/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "unstract-agent-kv-schema" version = "0.1.0" -description = "Agent-KV keys.json schema compiler — single source of truth for API validation and the extraction engine" +description = "Agent-KV keys.json schema compiler and submit-time validation gate (the engine calls kv_schema.compile directly and does not re-apply the caps — see compile.py)" requires-python = ">=3.12" dependencies = [] diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py index 5eb2c9bb1e..a7ecc37611 100644 --- a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py @@ -207,6 +207,83 @@ def _reject_unsafe_regex(path: str, pattern: str) -> None: ) +#: The formats ``validators._check_one`` actually implements. Anything else is +#: passed through as a free-text LLM hint, which is deliberate (see the +#: ``format`` comment on ``KeySpec``) -- and is also why a typo in one of these +#: six is invisible: ``format: "Number"`` is not a known format, so it becomes a +#: hint, so ``validate_format`` returns True for every value forever and +#: ``/validate`` reports ``{"valid": true}``. The author configured validation +#: and got none, with no error anywhere to find it by. +_KNOWN_FORMATS = frozenset({"string", "number", "date", "currency", "enum", "regex"}) + + +def _reject_unusable_formats(key_specs, array_specs) -> None: + """Refuse declared formats that can never validate anything, or always fail. + + Three shapes, all accepted before review and all verified by execution: + + * ``format: "Number"`` -- a case variant of a known format. Silently + degrades to a free-text hint, disabling validation for that key. Rejected + with the intended spelling, rather than guessed at: a schema author who + meant the hint can lower-case it or reword it, and one who meant the + format gets told. + * ``format: "enum:"`` -- no values, so ``_check_one`` tests membership of + the empty set and EVERY non-empty value fails QA for the life of the job. + * ``format: "regex:"`` -- empty pattern, so ``re.fullmatch("", v)`` matches + nothing but the empty string, which ``validate_format`` already passes + before reaching the pattern. Same outcome: nothing can ever conform. + + The last two are worse than the first: they do not disable validation, they + invert it, and the result is a document that fails QA no matter what is on + the page. + """ + for kspec in _every_leaf(key_specs, array_specs): + fmt = kspec.format + lowered = fmt.casefold() + if fmt not in _KNOWN_FORMATS and lowered in _KNOWN_FORMATS: + raise SchemaError( + f"'{kspec.path}' declares format {fmt!r}, which is not a known " + f"format and is therefore treated as free text -- no validation " + f"would run. Did you mean {lowered!r}?" + ) + if fmt == "enum" and not kspec.enum_values: + raise SchemaError( + f"'{kspec.path}' declares an enum with no values " + f"(e.g. 'enum:paid,unpaid'); as written no value can ever conform." + ) + if fmt == "regex" and not kspec.regex_pattern: + raise SchemaError( + f"'{kspec.path}' declares an empty regex; as written no value " + f"can ever conform." + ) + + +def _reject_unknown_key_columns(array_specs) -> None: + """Refuse an array's ``_key`` that names a column the array does not declare. + + ``key_column`` selects row identity for scoring. A name with no matching + column is not an error anywhere downstream -- every row simply misses, and + the array silently falls back to positional identity. So a typo costs + accuracy on exactly the arrays the author cared enough about to key, and + reports nothing. + """ + for aspec in array_specs: + if not aspec.key_column: + continue # '' is the documented "positional identity" default + columns = {s.path for s in aspec.item_specs} + if aspec.key_column not in columns: + raise SchemaError( + f"array '{aspec.path}' declares _key " + f"{aspec.key_column!r}, which is not one of its columns " + f"({sorted(columns)})." + ) + + +def _every_leaf(key_specs, array_specs): + """Scalar leaves and array columns, which carry the same per-leaf rules.""" + return list(key_specs) + [s for a in array_specs for s in a.item_specs] + + def compile_schema(spec: dict, caps: SchemaCaps | None = None) -> CompiledSchema: caps = caps or SchemaCaps() if not isinstance(spec, dict): @@ -228,6 +305,8 @@ def compile_schema(spec: dict, caps: SchemaCaps | None = None) -> CompiledSchema _enforce_shape_caps(key_specs, array_specs, caps) _enforce_leaf_caps(key_specs, array_specs, caps) + _reject_unusable_formats(key_specs, array_specs) + _reject_unknown_key_columns(array_specs) constraints = _validated_constraints(spec, caps) return CompiledSchema( @@ -251,7 +330,7 @@ def _enforce_shape_caps(key_specs, array_specs, caps: SchemaCaps) -> None: def _enforce_leaf_caps(key_specs, array_specs, caps: SchemaCaps) -> None: """Bound every leaf's author-supplied text, across scalars and array columns.""" - for kspec in key_specs + [s for a in array_specs for s in a.item_specs]: + for kspec in _every_leaf(key_specs, array_specs): _reject_unsafe_regex(kspec.path, kspec.regex_pattern) if len(kspec.regex_pattern) > caps.max_regex_len: raise SchemaError( diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py index 8c93096dba..a64b8bc9c9 100644 --- a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/constraints.py @@ -1,15 +1,37 @@ -"""Cross-field consistency (spec §18.3): a fail-closed evaluator for schema-author-declared +"""Cross-field consistency (spec §18.3): an ADVISORY evaluator for schema-author-declared constraints over the NORMALIZED key values. No eval/exec — a static AST allowlist (Compare/BoolOp/ BinOp/UnaryOp/Name/Attribute-path/Constant only). Operands resolve to normalized values; a missing/ -empty/un-coercible operand SKIPS the constraint (advisory), never crashes. Returns violated exprs. +empty/un-coercible operand SKIPS the constraint, never crashes. Returns violated exprs. + +Two senses of "fail-closed" were conflated here, and this docstring used to +claim the wrong one. What IS fail-closed is the *grammar*: anything outside the +allowlist is refused rather than executed, so no constraint can run code. What +is NOT fail-closed is the *outcome*: a constraint that cannot be evaluated -- +missing operand, mixed types, or an unexpected exception -- is dropped, and +``evaluate_constraints`` records only ``is False`` as a violation. So a dropped +constraint is indistinguishable, in the return value, from one that passed. + +That matters because dropping is REACHABLE, not theoretical: `_BIN` includes +`truediv`, so `unit_price == line_total / quantity` is an accepted constraint, +and a row where `quantity` normalizes to `0.0` raises `ZeroDivisionError`. The +QA result then reports ``violations: []`` -- a positive assurance that nothing +was checked. + +Until the result shape can carry a third state (skipped-with-reason), every +drop is LOGGED, so the condition is at least diagnosable from the worker logs +rather than silent. Surfacing it in the QA document is a public contract change +on the `kv` extractor, which this deployment does not ship; tracked separately. """ import ast +import logging import math import operator from .validators import coerce_number +logger = logging.getLogger(__name__) + # Equality on these values is TOLERANT, not exact. Operands are normalized # currency/number values that arrived as floats, so binary floating point makes # exact `==` wrong for the thing constraints exist to express. The feature's own @@ -148,7 +170,13 @@ def _aggregate(node: ast.Call, arrays: dict[str, list[dict[str, str]]]): (strips commas/$/%, drops non-finite/empty -> None); such cells are skipped (not zeroed). `count('a.col')` counts rows whose column value is NON-EMPTY (text columns like sku/description count too); `count('a')` is the row count. A sum/min/max/avg over zero - usable cells raises `_Skip` (advisory) rather than guessing 0. + usable cells raises `_Skip` (advisory) rather than guessing 0, and sum/avg additionally + require EVERY row to contribute -- see the comment at that check. + + "FAIL-CLOSED" above is about the grammar, not the outcome: a deviation is + refused rather than executed. A `_Skip` is then reported by + `evaluate_constraints` as "no violation", which is the module docstring's + subject. """ fn, array_path, column = _parse_agg_call(node) rows = arrays.get(array_path) @@ -167,6 +195,20 @@ def _aggregate(node: ast.Call, arrays: dict[str, list[dict[str, str]]]): nums = [n for r in rows if (n := coerce_number(r.get(column))) is not None] if not nums: # nothing usable -> advisory skip raise _Skip() + # A PARTIAL sum or average is not the column's sum or average -- it is a + # smaller number that will then be compared against a scalar key covering + # the whole column, and report a violation on a CORRECT document. One + # blank or un-parseable cell in a 40-row invoice is enough. That is the + # same false-positive class the comparison tolerance above exists to + # eliminate, arriving by a different route, so sum/avg require every row + # to contribute and otherwise skip (advisory) rather than guess. + # + # min/max/count are deliberately NOT subject to this: they are meaningful + # over a subset by construction (the smallest value present, the largest + # value present, the number of rows carrying one), and `count` has already + # returned above. + if fn in ("sum", "avg") and len(nums) != len(rows): + raise _Skip() # `math.fsum`, not the builtin `sum`: exactly-rounded, so accumulation error # does not grow with row count. The builtin accumulates left to right, and # on a long array that drift is the one thing that could push a CORRECT @@ -253,13 +295,32 @@ def _evaluate_one( try: tree = ast.parse(expr, mode="eval") except SyntaxError: + # Unreachable from the API: `compile._check_constraint_syntax` parses + # every constraint at submit. Still logged, because reaching it means + # a constraint got in past that gate. + logger.warning("agent-kv constraint does not parse, skipping: %r", expr) return None try: return _truth(tree.body, values, arrays) except _Skip: + # The ordinary, expected drop: an operand is missing, empty, or not + # like-typed. Debug rather than warning -- on a document with optional + # keys this is normal and would otherwise be per-constraint noise. + logger.debug("agent-kv constraint skipped (operand unusable): %r", expr) return None except Exception: - return None # fail-closed: any surprise -> skip, never crash the pipeline + # NOT fail-closed, whatever the previous comment on this line said: the + # caller turns None into "no violation", so this returns a pass for a + # check that never ran. `ZeroDivisionError` on a quantity of 0 reaches + # here. Keeping the catch is still right -- one bad constraint must not + # fail the whole extraction -- but it is logged at exception level so + # the drop is findable, which is the part that was missing. + logger.exception( + "agent-kv constraint raised and was DROPPED (reported as no " + "violation, not as a failure): %r", + expr, + ) + return None def evaluate_constraints( diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/dataclasses.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/dataclasses.py index 10b72ef1b2..544c9c5efb 100644 --- a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/dataclasses.py +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/dataclasses.py @@ -6,8 +6,16 @@ from dataclasses import dataclass, field +# Both specs are FROZEN. They are compile output: produced once by +# `kv_schema._walk` and then read by the prompt generator, the QA pass and the +# constraint evaluator. Nothing assigns to a field (the one derived variant, +# `normalizers`' scalar view of a multivalued leaf, already goes through +# `dataclasses.replace`), so freezing costs nothing and makes a stray mutation +# in a later stage a loud AttributeError instead of a spec that disagrees with +# the schema the caller submitted. -@dataclass + +@dataclass(frozen=True) class KeySpec: """One compiled leaf key from the user's nested key schema.""" @@ -21,7 +29,7 @@ class KeySpec: multivalued: bool = False # value is a comma-separated string -@dataclass +@dataclass(frozen=True) class ArraySpec: """One compiled FLAT-array node (P8a). `path` = dotted array location (e.g. 'line_items', 'invoice.lines'). `item_specs` = the declared columns as row-LOCAL scalar KeySpecs (their diff --git a/unstract/agent-kv-schema/tests/test_compile.py b/unstract/agent-kv-schema/tests/test_compile.py index ff3d4c7c10..12a3a707d4 100644 --- a/unstract/agent-kv-schema/tests/test_compile.py +++ b/unstract/agent-kv-schema/tests/test_compile.py @@ -1,5 +1,7 @@ """compile_schema: structural validation + caps on top of the ported compiler.""" +from dataclasses import FrozenInstanceError + import pytest from unstract.agent_kv_schema import ( @@ -248,3 +250,145 @@ def test_non_overlapping_alternation_still_compiles(pattern): patterns -- `(?:a|b)+` is linear. """ compile_schema(_schema(pattern)) + + +# -------------------------------------------------------------------------- +# 2.18: formats that can never validate anything, or always fail. +# +# Free-text `format` stays an LLM hint -- that is deliberate, see the `format` +# comment on KeySpec. The three shapes below are not hints, they are mistakes +# with no observable symptom, all three verified by execution before the fix. +# -------------------------------------------------------------------------- + + +def _one_key(fmt: str) -> dict: + return {"amount": {"description": "An amount", "format": fmt}} + + +@pytest.mark.parametrize( + ("fmt", "intended"), + [ + ("Number", "number"), + ("NUMBER", "number"), + ("Date", "date"), + ("Currency", "currency"), + ("String", "string"), + ("Enum", "enum"), + ("Regex", "regex"), + ], +) +def test_a_case_variant_of_a_known_format_is_refused(fmt, intended): + """`format: "Number"` silently became a free-text hint. + + `validate_format("not a number at all", spec)` then returned True for + every value for the life of the job, and `/validate` reported + `{"valid": true}` -- the author configured validation and got none. + """ + with pytest.raises(SchemaError, match=f"Did you mean '{intended}'"): + compile_schema(_one_key(fmt)) + + +@pytest.mark.parametrize("fmt", ["number", "date", "currency", "string"]) +def test_the_known_formats_still_compile(fmt): + compile_schema(_one_key(fmt)) + + +@pytest.mark.parametrize( + "fmt", + [ + "the invoice total in USD", # a real hint, which is the point of free text + "amount", + "numeric-ish", # near a known format but not a case variant of one + "dates", + ], +) +def test_free_text_format_is_still_accepted_as_an_llm_hint(fmt): + compile_schema(_one_key(fmt)) + + +@pytest.mark.parametrize("fmt", ["enum:", "enum", "enum: , ,"]) +def test_an_enum_with_no_values_is_refused(fmt): + """`enum_values=[]` means `_check_one` tests membership of the empty set, + so every non-empty value fails QA forever. This does not disable + validation, it inverts it. + """ + with pytest.raises(SchemaError, match="enum with no values"): + compile_schema(_one_key(fmt)) + + +def test_an_enum_with_values_still_compiles(): + compiled = compile_schema(_one_key("enum:paid,unpaid")) + assert compiled.key_specs[0].enum_values == ["paid", "unpaid"] + + +@pytest.mark.parametrize("fmt", ["regex:", "regex"]) +def test_an_empty_regex_is_refused(fmt): + with pytest.raises(SchemaError, match="empty regex"): + compile_schema(_one_key(fmt)) + + +def test_the_same_rules_apply_to_array_columns(): + """Array columns are leaves too, and were checked by nothing.""" + spec = { + "lines": { + "description": "One row per line", + "_array": {"total": {"description": "Line total", "format": "Currency"}}, + } + } + with pytest.raises(SchemaError, match="Did you mean 'currency'"): + compile_schema(spec) + + +# -------------------------------------------------------------------------- +# 2.18 (second half): `_key` naming a column that does not exist. +# -------------------------------------------------------------------------- + + +def test_a_key_column_that_does_not_exist_is_refused(): + """A `_key` typo is not an error anywhere downstream. + + Every row misses, the array silently falls back to positional identity, + and the cost is accuracy on exactly the arrays the author cared enough + about to key. Nothing is reported. + """ + spec = { + "lines": { + "description": "One row per line", + "_key": "skew", # the column is 'sku' + "_array": { + "sku": {"description": "SKU"}, + "total": {"description": "Line total"}, + }, + } + } + with pytest.raises(SchemaError, match="_key 'skew'"): + compile_schema(spec) + + +def test_a_key_column_that_exists_still_compiles(): + compiled = compile_schema(VALID) + assert [a.key_column for a in compiled.array_specs] == ["sku"] + + +def test_an_array_with_no_key_column_is_positional_and_accepted(): + spec = { + "lines": { + "description": "One row per line", + "_array": {"sku": {"description": "SKU"}}, + } + } + compiled = compile_schema(spec) + assert compiled.array_specs[0].key_column == "" + + +# -------------------------------------------------------------------------- +# 2.18 (third): the specs are compile OUTPUT and must not be mutable. +# -------------------------------------------------------------------------- + + +def test_compiled_specs_are_frozen(): + compiled = compile_schema(VALID) + with pytest.raises(FrozenInstanceError): + compiled.key_specs[0].format = "number" + with pytest.raises(FrozenInstanceError): + compiled.array_specs[0].key_column = "total" diff --git a/unstract/agent-kv-schema/tests/test_constraints.py b/unstract/agent-kv-schema/tests/test_constraints.py index e69de29bb2..43fa014923 100644 --- a/unstract/agent-kv-schema/tests/test_constraints.py +++ b/unstract/agent-kv-schema/tests/test_constraints.py @@ -0,0 +1,258 @@ +"""The constraint evaluator's operator/aggregate matrix, and its drop behaviour. + +This file was 0 bytes against a 258-line module -- an empty file with that name +reads as coverage, which is worse than no file (reported as 2.20 in the branch +review). What it covers now is the part the review found wrong (2.19): the +module called itself fail-closed while a constraint that raises is reported as +"no violation", and a partial sum over a column with one blank cell produced a +false violation on a correct document. + +`test_constraints_tolerance.py` covers the float-tolerance derivation; this +covers everything else. +""" + +import ast +import logging + +import pytest + +from unstract.agent_kv_schema import evaluate_constraints +from unstract.agent_kv_schema.compile import _ALLOWED_CALLS, _ALLOWED_NODES +from unstract.agent_kv_schema.constraints import _AGG, _BIN, _CMP + +_ROWS = { + "lines": [ + {"amount": "100.00", "qty": "2", "sku": "A"}, + {"amount": "50.00", "qty": "1", "sku": "B"}, + ] +} + + +# -------------------------------------------------------------------------- +# The comparison and arithmetic matrix +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("expr", "values", "violated"), + [ + ("a == b", {"a": "1", "b": "1"}, False), + ("a == b", {"a": "1", "b": "2"}, True), + ("a != b", {"a": "1", "b": "2"}, False), + ("a != b", {"a": "1", "b": "1"}, True), + ("a < b", {"a": "1", "b": "2"}, False), + ("a < b", {"a": "2", "b": "2"}, True), + ("a <= b", {"a": "2", "b": "2"}, False), + ("a > b", {"a": "3", "b": "2"}, False), + ("a >= b", {"a": "2", "b": "2"}, False), + # chained, Python-style + ("a < b < c", {"a": "1", "b": "2", "c": "3"}, False), + ("a < b < c", {"a": "1", "b": "3", "c": "2"}, True), + # arithmetic + ("total == net + tax", {"total": "110", "net": "100", "tax": "10"}, False), + ("total == net + tax", {"total": "111", "net": "100", "tax": "10"}, True), + ("net == total - tax", {"net": "100", "total": "110", "tax": "10"}, False), + ("total == qty * price", {"total": "20", "qty": "4", "price": "5"}, False), + ("price == total / qty", {"price": "5", "total": "20", "qty": "4"}, False), + ("a == -b", {"a": "-5", "b": "5"}, False), + # boolean composition + ("a > 0 and b > 0", {"a": "1", "b": "1"}, False), + ("a > 0 and b > 0", {"a": "1", "b": "-1"}, True), + ("a > 0 or b > 0", {"a": "-1", "b": "1"}, False), + ("not (a > b)", {"a": "1", "b": "2"}, False), + # thousands separators and currency symbols coerce numerically + ("a == b", {"a": "9,000", "b": "9000"}, False), + ("a == b", {"a": "$1,234.50", "b": "1234.5"}, False), + # ISO dates compare lexicographically as strings + ("start < end", {"start": "2026-01-01", "end": "2026-02-01"}, False), + ("start < end", {"start": "2026-03-01", "end": "2026-02-01"}, True), + ], +) +def test_operator_matrix(expr, values, violated): + assert evaluate_constraints([expr], values, {}) == ([expr] if violated else []) + + +@pytest.mark.parametrize( + "expr", + [ + "a == b", # 'a' missing entirely + "missing == 1", # operand absent + "a == 1", # operand empty string + "a < b", # number vs string: not like-typed + "a == 'nan'", # non-finite token stays a string + ], +) +def test_unusable_operands_are_skipped_not_violations(expr): + values = {"a": "", "b": "text"} + assert evaluate_constraints([expr], values, {}) == [] + + +# -------------------------------------------------------------------------- +# Aggregates +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("expr", "values", "violated"), + [ + ("total == sum('lines.amount')", {"total": "150.00"}, False), + ("total == sum('lines.amount')", {"total": "150.01"}, True), + ("n == count('lines')", {"n": "2"}, False), + ("n == count('lines')", {"n": "3"}, True), + ("n == count('lines.sku')", {"n": "2"}, False), + ("lo == min('lines.amount')", {"lo": "50"}, False), + ("hi == max('lines.amount')", {"hi": "100"}, False), + ("mean == avg('lines.amount')", {"mean": "75"}, False), + ("mean == avg('lines.amount')", {"mean": "76"}, True), + ], +) +def test_aggregate_matrix(expr, values, violated): + assert evaluate_constraints([expr], values, _ROWS) == ([expr] if violated else []) + + +@pytest.mark.parametrize( + "expr", + [ + "x == sum('nosucharray.amount')", # array not supplied + "x == sum('lines')", # sum needs a column + "x == avg('lines')", + "x == min('lines')", + "x == sum('lines.sku')", # no numeric cell at all + "x == median('lines.amount')", # not in the allowlist + "x == sum('lines.amount', 2)", # two args + "x == sum(column)", # arg is not a string literal + ], +) +def test_unusable_aggregates_are_skipped_not_violations(expr): + assert evaluate_constraints([expr], {"x": "150"}, _ROWS) == [] + + +def test_count_of_a_column_counts_non_empty_cells_only(): + rows = {"lines": [{"sku": "A"}, {"sku": ""}, {"sku": " "}, {"sku": "C"}]} + assert evaluate_constraints(["n == count('lines.sku')"], {"n": "2"}, rows) == [] + assert evaluate_constraints(["n == count('lines')"], {"n": "4"}, rows) == [] + + +# -------------------------------------------------------------------------- +# 2.19: a PARTIAL sum/avg must skip, not report a false violation +# -------------------------------------------------------------------------- + +_PARTIAL = { + "lines": [ + {"amount": "100.00"}, + {"amount": ""}, # blank cell: OCR missed it, or the row has no amount + {"amount": "50.00"}, + ] +} + + +def test_a_partial_sum_skips_rather_than_reporting_a_false_violation(): + """The document is CORRECT: 100 + + 50 against a stated 200. + + Summing the two readable cells gives 150, which is not 200 -- so before + the fix this reported a violation on a correct invoice. One blank cell in + a long array was enough. The sum is not the column's sum, so there is + nothing to compare and the constraint is advisory-skipped. + """ + expr = "total == sum('lines.amount')" + assert evaluate_constraints([expr], {"total": "200.00"}, _PARTIAL) == [] + + +def test_a_partial_avg_also_skips(): + expr = "mean == avg('lines.amount')" + assert evaluate_constraints([expr], {"mean": "66.67"}, _PARTIAL) == [] + + +def test_an_un_parseable_cell_counts_as_missing_for_sum(): + """`total` is the document's real total, including the cell OCR mangled. + + Summing only the readable cell gives 100 against a stated 250, so without + the guard this is a violation on a correct document. + """ + rows = {"lines": [{"amount": "100.00"}, {"amount": "n/a"}]} + expr = "total == sum('lines.amount')" + assert evaluate_constraints([expr], {"total": "250.00"}, rows) == [] + + +@pytest.mark.parametrize("fn", ["min", "max", "count"]) +def test_min_max_and_count_stay_total_over_a_subset(fn): + """These are meaningful over the rows that HAVE a value, so they must not skip.""" + expected = {"min": "50.00", "max": "100.00", "count": "2"}[fn] + expr = f"x == {fn}('lines.amount')" + assert evaluate_constraints([expr], {"x": expected}, _PARTIAL) == [] + + +# -------------------------------------------------------------------------- +# 2.19: a constraint that RAISES is dropped and reported as no violation. +# That stays true (one bad constraint must not fail an extraction) but it is +# now logged, which is the part that was missing. +# -------------------------------------------------------------------------- + + +def test_a_dropped_constraint_is_logged_at_exception_level(caplog): + """`quantity` normalizes to 0 -> ZeroDivisionError -> dropped. + + `_BIN` includes truediv and `compile._ALLOWED_NODES` includes ast.Div, so + this expression is accepted at submit: the path is reachable, not + theoretical. + """ + expr = "unit_price == line_total / quantity" + values = {"unit_price": "5", "line_total": "20", "quantity": "0"} + with caplog.at_level(logging.ERROR, logger="unstract.agent_kv_schema.constraints"): + assert evaluate_constraints([expr], values, {}) == [] + assert any( + "DROPPED" in r.getMessage() and expr in r.getMessage() for r in caplog.records + ), ( + "a constraint that raised was dropped silently; the only signal that " + "nothing was checked is this log line" + ) + + +def test_an_ordinary_skip_is_not_logged_as_an_error(caplog): + """A missing operand is normal on a document with optional keys.""" + with caplog.at_level(logging.WARNING, logger="unstract.agent_kv_schema.constraints"): + assert evaluate_constraints(["a == b"], {"a": "1"}, {}) == [] + assert caplog.records == [] + + +def test_one_dropped_constraint_does_not_stop_the_others(): + exprs = ["unit_price == line_total / quantity", "total == net"] + values = { + "unit_price": "5", + "line_total": "20", + "quantity": "0", + "total": "1", + "net": "2", + } + assert evaluate_constraints(exprs, values, {}) == ["total == net"] + + +# -------------------------------------------------------------------------- +# 2.20: the two hand-maintained allowlists live in two files with nothing +# enforcing agreement. compile.py decides what a submit ACCEPTS; constraints.py +# decides what the evaluator can RUN. A node accepted but not runnable is a +# constraint that is silently skipped for every document; a node runnable but +# not accepted is dead code. +# -------------------------------------------------------------------------- + + +def test_the_submit_allowlist_and_the_evaluator_agree_on_comparisons(): + accepted = {n for n in _ALLOWED_NODES if issubclass(n, ast.cmpop)} + assert accepted == set(_CMP), ( + "compile._ALLOWED_NODES and constraints._CMP disagree: a comparison " + "accepted at submit but absent from _CMP is skipped for every document" + ) + + +def test_the_submit_allowlist_and_the_evaluator_agree_on_arithmetic(): + accepted = {n for n in _ALLOWED_NODES if issubclass(n, ast.operator)} + assert accepted == set( + _BIN + ), "compile._ALLOWED_NODES and constraints._BIN disagree on binary operators" + + +def test_the_submit_allowlist_and_the_evaluator_agree_on_aggregates(): + assert _ALLOWED_CALLS == _AGG, ( + "compile._ALLOWED_CALLS and constraints._AGG disagree: a function name " + "accepted at submit but absent from _AGG is skipped for every document" + ) diff --git a/workers/shared/api/internal_client.py b/workers/shared/api/internal_client.py index 5a82c61b60..329659dd30 100644 --- a/workers/shared/api/internal_client.py +++ b/workers/shared/api/internal_client.py @@ -1669,8 +1669,17 @@ def agent_kv_stage_report( job_id: Agent-KV job ID. org_id: Organization ID (required by the endpoint's body, not the URL — mirrors ``StageReportView``/``FinalizeView``). - stage: Pipeline stage name (e.g. ``"extract"``). - status: Stage status (e.g. ``"started"``, ``"completed"``). + stage: Pipeline stage name. Must be one of the stage names the + job's extractor declares (``agent_kv.constants + .STAGE_NAMES_BY_EXTRACTOR``) -- ``"table_extraction"`` for + ``table``, one of ``STAGE_NAMES`` for ``kv``. The endpoint + only checks the name is non-empty, but ``_status_document`` + filters a job's recorded stages through that list, so an + off-list name is stored and then dropped from every status + response. + status: Stage status -- ``"running"`` or ``"done"``, the only two + values ``StageReportView`` accepts. (These examples read + ``"started"``/``"completed"`` before review; both are 400s.) seconds: Optional stage duration in seconds. counters: Optional stage counters to record. From f163650dc2f25384f9c5401f86bc3c87f745d950 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 7 Oct 2026 10:56:34 +0530 Subject: [PATCH 13/54] UN-4232 [FIX] Give agent-kv's `extractor` column choices and no default Review finding 2.17. `extractor` was the one stringly-typed field on the model without `choices=` while `status` has had them since 0001, so nothing but a reader's memory connected the column to `EXTRACTOR_ROUTES`. The sharper half is the default. Migration 0002 added the column with `default="kv"`, which was the historical truth then -- the API accepted exactly one extractor and it was always `kv` -- and became a mis-filing trap the moment `table` existed. A creation path omitting `extractor=` files a TABLE job as `kv`, and `kv` IS a valid key in `STAGE_NAMES_BY_EXTRACTOR`, so `_status_document` hands back the KV stage list and silently drops `table_extraction` from every status response. The job runs, the caller is billed, the stages array comes back empty, and `_status_document`'s unknown-extractor warning does NOT fire, because nothing is wrong as far as the filter can tell. Only the one production creation path exists today and it passes `extractor=` explicitly, so this is latent rather than live. Removed rather than re-pointed at `table`: which extractor ran is a fact about the job, not something with a sensible default. With no default an omission is loud -- `""` matches no route, so `dispatch_job` raises, the job terminalizes FAILED with an error the caller can see, and the status warning does fire. `JobExtractor` lists KV even though this deployment refuses it. The column records which extractor RAN, and the rows 0002 back-filled legitimately say `kv`; routability is `EXTRACTOR_ROUTES`' job. The two sets are deliberately different, so neither is derived from the other -- deriving would either quietly re-enable the extractor or make old rows unreadable. `test_recordable_extractors_are_a_superset_of_routable_ones` pins that relationship as a strict subset. Migration 0005 is a schema no-op on Postgres: Django keeps `choices` and a field `default` in Python only -- no CHECK constraint, no column DEFAULT -- so the AlterField changes model state and emits no DDL touching data. `makemigrations --check --dry-run` reports no changes, and the leaf check shows a single leaf per app. Two existing tests failed on this and were right to: - `test_the_extractor_column_defaults_to_kv` asserted `== "kv"`, pinning the defect as the contract. Renamed, inverted, and it now also asserts `kv` remains a legal value to READ back, which is what the retired-extractor status test below it depends on. - Two `test_job_views` tests built jobs with KV stage names and `kv`-namespaced result assertions while relying on the column default to supply the extractor. Both now pass it explicitly: the filtering is load-bearing for what they assert, and riding on a default made that invisible in the test that depends on it. 251 backend tests (was 248). Mutation-checked: restoring `default=V1_EXTRACTOR_NAME` fails 2, dropping `choices` fails 3. Co-Authored-By: Claude Opus 5 --- .../0005_agentkvjob_extractor_choices.py | 32 ++++++++++++ backend/agent_kv/models.py | 37 +++++++++++++- backend/agent_kv/tests/test_job_views.py | 10 ++++ backend/agent_kv/tests/test_models.py | 49 +++++++++++++++++++ .../tests/test_table_extractor_routing.py | 23 +++++++-- 5 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 backend/agent_kv/migrations/0005_agentkvjob_extractor_choices.py diff --git a/backend/agent_kv/migrations/0005_agentkvjob_extractor_choices.py b/backend/agent_kv/migrations/0005_agentkvjob_extractor_choices.py new file mode 100644 index 0000000000..7e77da5595 --- /dev/null +++ b/backend/agent_kv/migrations/0005_agentkvjob_extractor_choices.py @@ -0,0 +1,32 @@ +"""Give `extractor` its `choices` and take away its `default`. + +Schema no-op on Postgres: Django keeps both `choices` and a field `default` in +Python only -- no CHECK constraint, no column DEFAULT -- so this `AlterField` +changes model state and emits no DDL that touches data. Existing rows keep +whatever they recorded, including the `kv` rows migration 0002 back-filled. + +The `default="kv"` this removes was correct when 0002 added the column (the +API accepted one extractor and it was always `kv`) and became a trap once +`table` existed: an omitted `extractor=` filed a table job as `kv`, which IS a +valid key in `STAGE_NAMES_BY_EXTRACTOR`, so the status endpoint returned the KV +stage list and dropped `table_extraction` silently. See the field comment on +the model. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("agent_kv", "0004_pg_periodic_tasks"), + ] + + operations = [ + migrations.AlterField( + model_name="agentkvjob", + name="extractor", + field=models.CharField( + choices=[("kv", "Kv"), ("table", "Table")], max_length=32 + ), + ), + ] diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index 7afb051364..149a3136fe 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -6,7 +6,7 @@ from utils.models.base_model import BaseModel from utils.models.organization_mixin import DefaultOrganizationMixin -from agent_kv.constants import V1_EXTRACTOR_NAME +from agent_kv.constants import TABLE_EXTRACTOR_NAME, V1_EXTRACTOR_NAME class AgentKVKey(DefaultOrganizationMixin, BaseModel): @@ -50,6 +50,23 @@ class JobStatus(models.TextChoices): CANCELLED = "CANCELLED" +class JobExtractor(models.TextChoices): + """The extractor names a job row may record. + + `status` has had `choices` since 0001 and `extractor` was the one + stringly-typed field without them -- so nothing but a reader's memory + connected the column to `EXTRACTOR_ROUTES`. + + KV is listed even though this deployment refuses it: the column records + which extractor RAN, and rows written before the carve-out legitimately + say `kv`. Routability is `EXTRACTOR_ROUTES`' job, and these two sets are + deliberately not the same thing -- see `test_table_extractor_routing.py`. + """ + + KV = V1_EXTRACTOR_NAME + TABLE = TABLE_EXTRACTOR_NAME + + class AgentKVJob(DefaultOrganizationMixin, BaseModel): TERMINAL = frozenset({JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}) @@ -64,7 +81,23 @@ class AgentKVJob(DefaultOrganizationMixin, BaseModel): # Which extractor this job ran. v1 dispatches exactly one per job, but # WHICH one is now a choice, and status/result key their payloads by it -- # without this column a table job's output would be filed under `kv`. - extractor = models.CharField(max_length=32, default=V1_EXTRACTOR_NAME) + # + # NO DEFAULT, deliberately. Migration 0002 added this column with + # `default="kv"`, which was the historical truth at the time (the API + # accepted exactly one extractor and it was always `kv`) and is now a + # trap: a creation path that omits `extractor=` files a TABLE job as + # `kv`, and `kv` is a valid key in `STAGE_NAMES_BY_EXTRACTOR`, so + # `_status_document` hands back the KV stage list and silently drops + # `table_extraction` from every status response. The job runs, the caller + # is billed, and the stages array is empty with no warning logged -- + # because nothing is wrong as far as the filter can tell. + # + # With no default, an omission is loud instead: `""` matches no route, so + # `dispatch_job` raises and the job terminalizes as FAILED with an error + # the caller can see, and `_status_document` logs the unknown-extractor + # warning. Which extractor ran is a fact about the job, not something with + # a sensible default. + extractor = models.CharField(max_length=32, choices=JobExtractor.choices) status = models.CharField( max_length=16, choices=JobStatus.choices, diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index a5332748b3..4416027756 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -16,6 +16,7 @@ from agent_kv import execution_views as ev # noqa: E402 from agent_kv import execution_views_result as evr # noqa: E402 +from agent_kv.constants import V1_EXTRACTOR_NAME # noqa: E402 from agent_kv.models import AgentKVJob, AgentKVKey, JobStatus # noqa: E402 from agent_kv.tests._factories import kv_key # noqa: E402 @@ -83,6 +84,12 @@ def test_every_job_scoped_view_filters_by_organization(m_keys, m_jobs, view, met def test_status_running_builds_ordered_stages_and_lowercases_status(m_keys, m_jobs): m_keys.get.return_value = kv_key() job = AgentKVJob( + # Explicit, not the column default: the stage names below are the KV + # list, and `_status_document` filters a job's stages through + # STAGE_NAMES_BY_EXTRACTOR[job.extractor]. This used to ride on + # `extractor` defaulting to `kv`, which made a load-bearing fact + # invisible in the test that depends on it. + extractor=V1_EXTRACTOR_NAME, status=JobStatus.RUNNING, stage="extraction", stages={ @@ -160,6 +167,9 @@ def test_result_after_expiry_is_404(m_keys, m_jobs, m_read): def test_result_happy_path_returns_read_result_payload(m_keys, m_jobs, m_read): m_keys.get.return_value = kv_key() job = AgentKVJob( + # Explicit: the assertions below namespace the payload under `kv`, + # which is `job.extractor`, not a constant of the response shape. + extractor=V1_EXTRACTOR_NAME, status=JobStatus.COMPLETED, result_ref="org/o/agent_kv/j/result.json", expires_at=timezone.now() + timedelta(days=1), diff --git a/backend/agent_kv/tests/test_models.py b/backend/agent_kv/tests/test_models.py index 231252c135..56d32a800f 100644 --- a/backend/agent_kv/tests/test_models.py +++ b/backend/agent_kv/tests/test_models.py @@ -94,3 +94,52 @@ def test_mark_terminal_accepts_every_terminal_status(m_objects, good_status): ) is True ) + + +# -------------------------------------------------------------------------- +# 2.17: `extractor` was the one stringly-typed field without `choices`, and it +# defaulted to the extractor this build cannot run. +# -------------------------------------------------------------------------- + + +def test_extractor_declares_its_choices(): + field = AgentKVJob._meta.get_field("extractor") + assert {value for value, _ in field.choices} == {"kv", "table"} + + +def test_extractor_has_no_default(): + """An omitted `extractor=` must be loud, not silently filed as `kv`. + + `kv` IS a valid key in `STAGE_NAMES_BY_EXTRACTOR`, so a table job filed + under it gets the KV stage list and `table_extraction` is dropped from + every status response -- the job runs, the caller is billed, the stages + array comes back empty and nothing logs a warning, because nothing is + wrong as far as the filter can tell. + + With no default the omission produces `""`, which matches no route: the + dispatch raises, the job terminalizes FAILED with a visible error, and + `_status_document` logs the unknown-extractor warning. + """ + field = AgentKVJob._meta.get_field("extractor") + assert not field.has_default(), ( + "migration 0002's `default='kv'` was the historical truth then and is " + "a mis-filing trap now; see the field comment" + ) + assert AgentKVJob(organization_id="o").extractor == "" + + +def test_recordable_extractors_are_a_superset_of_routable_ones(): + """The two sets are deliberately different, so neither is derived. + + `choices` says what a ROW may record -- rows written before the carve-out + legitimately say `kv`. `EXTRACTOR_ROUTES` says what a submit may DISPATCH, + and `kv` is absent from it on purpose. Deriving either from the other + would quietly re-enable the extractor or make old rows unreadable. + """ + from agent_kv.constants import EXTRACTOR_ROUTES + + recordable = {value for value, _ in AgentKVJob._meta.get_field("extractor").choices} + assert set(EXTRACTOR_ROUTES) < recordable, ( + "every routable extractor must be recordable, and `kv` is recordable " + "without being routable" + ) diff --git a/backend/agent_kv/tests/test_table_extractor_routing.py b/backend/agent_kv/tests/test_table_extractor_routing.py index 07ded10fcd..696afb9573 100644 --- a/backend/agent_kv/tests/test_table_extractor_routing.py +++ b/backend/agent_kv/tests/test_table_extractor_routing.py @@ -209,11 +209,26 @@ def test_the_result_payload_keys_by_the_extractor_that_ran(): assert V1_EXTRACTOR_NAME not in payload["extractors"] -def test_the_extractor_column_defaults_to_kv(): - """The migration's default is the historical truth, not a guess: before - this column existed the API accepted exactly one extractor, always `kv`. +def test_the_extractor_column_has_no_default(): + """Was `test_the_extractor_column_defaults_to_kv`, asserting `== "kv"`. + + It pinned the defect as the contract. The default WAS the historical truth + when migration 0002 added the column -- the API accepted exactly one + extractor and it was always `kv` -- and it became a mis-filing trap the + moment `table` existed: `kv` is a valid key in `STAGE_NAMES_BY_EXTRACTOR`, + so a table job filed under it gets the KV stage list and + `table_extraction` is silently dropped from every status response, with + nothing logged because nothing looks wrong to the filter. + + `""` matches no route, so an omission now fails the dispatch visibly. The + row-level truth 0002 back-filled is untouched: existing `kv` rows keep + saying `kv`, which is what `test_a_job_whose_extractor_has_no_stage_list_ + reports_no_stages` below depends on. """ - assert AgentKVJob().extractor == V1_EXTRACTOR_NAME + assert AgentKVJob().extractor == "" + assert V1_EXTRACTOR_NAME in { + value for value, _ in AgentKVJob._meta.get_field("extractor").choices + }, "a pre-carve-out row must still be a legal value to READ back" def test_a_job_whose_extractor_has_no_stage_list_reports_no_stages(caplog): From e283b9ab2c29fbed479aeb44a076c8770dac7b6b Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 7 Oct 2026 11:00:45 +0530 Subject: [PATCH 14/54] UN-4232 [REFACTOR] Delete agent-kv's dead `compiled` serializer attribute Review finding 2.20. `ExtractorSerializer.compiled` was populated during validation and collected by `SubmitSerializer.validate_extractors` into a `{extractor_name: compiled schema}` dict that no non-test code read. `dispatch_job` sends `schema=entry["keys"]` -- the raw dict -- and the engine compiles it itself. Plumbing the `CompiledSchema` through instead was the other option the finding offered, and the queue does not allow it: the compiled form would have to survive a JSON round-trip to the executor, which is precisely why the engine recompiles (`compile.py`'s docstring already says the caps are a submit-time gate, not an invariant the engine re-checks). An attribute that is written and never read reads as plumbing that exists, so it is gone. On this deployment the dict was always `{"table": None}` anyway: `table` has a dedicated keys serializer, so the branch that set `compiled` never ran. `compile_schema` is still called in the no-keys-serializer fall-through, for what it REFUSES -- the 400 is the only reason it is there, and the comment now says so, so a later reader does not delete the call as unused. That branch is DORMANT here: `kv` is the only extractor without a keys serializer and `validate_name` refuses it first, so nothing in the request path reaches it. It is still the contract for the next extractor added without one, and nothing covered it, so three tests call it directly -- the bad-schema 400, the raw spec passing through identically, and that neither serializer carries a `compiled` attribute any more. Also fixed the now-wrong comment in `test_submit_view.py`, which said the options dict "must ... carry the compiled schema". 254 backend tests (was 251). Mutation-checked: deleting the `compile_schema` call fails the 400 test, reintroducing the attribute fails the third. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_serializers.py | 21 +++++---- .../agent_kv/tests/test_submit_serializer.py | 47 +++++++++++++++++++ backend/agent_kv/tests/test_submit_view.py | 10 ++-- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/backend/agent_kv/execution_serializers.py b/backend/agent_kv/execution_serializers.py index 8d4f750b8d..6dada100d0 100644 --- a/backend/agent_kv/execution_serializers.py +++ b/backend/agent_kv/execution_serializers.py @@ -169,8 +169,6 @@ class ExtractorSerializer(serializers.Serializer): keys = serializers.JSONField() options = serializers.DictField(required=False, default=dict) - compiled = None - def validate_name(self, v): if v not in SUPPORTED_EXTRACTORS: raise serializers.ValidationError( @@ -211,8 +209,19 @@ def _validated_keys(self, name: str, spec): serialized = json.dumps(spec, ensure_ascii=False).encode("utf-8") if len(serialized) > settings.AGENT_KV_MAX_SCHEMA_BYTES: raise serializers.ValidationError({"keys": "keys schema too large"}) + # Called for what it REFUSES, not for what it returns. The + # `CompiledSchema` used to be stashed on the serializer and collected + # into a `{name: compiled}` dict by `SubmitSerializer`, which no + # non-test code ever read: `dispatch_job` sends `schema=entry["keys"]`, + # the raw dict, and the engine re-compiles it on its own (see the + # docstring on `compile.py` -- the caps are a submit-time gate, not an + # invariant the engine re-checks). Plumbing the compiled form through + # instead is not an option the queue allows: it would have to survive + # JSON round-tripping to the executor, which is exactly why the engine + # recompiles. So the dead attribute is gone rather than left to read as + # plumbing that exists. What this call is for is the 400 below. try: - self.compiled = compile_schema(spec) + compile_schema(spec) except SchemaError as e: raise serializers.ValidationError({"keys": str(e)}) return spec @@ -265,8 +274,6 @@ class SubmitSerializer(serializers.Serializer): required=False, allow_blank=True, default="", max_length=1024 ) - #: ``{extractor_name: compiled schema}`` -- populated during validation. - compiled = None #: Measured page count of the uploaded document. None for Excel, which has #: no pre-OCR page concept. This is what metering and the status document #: report, and it is NOT what the page cap is compared against. @@ -317,7 +324,7 @@ def validate_extractors(self, raw): "multiple extractors are not supported yet; pass exactly one" ) - validated, compiled = [], {} + validated = [] for i, entry in enumerate(entries): if not isinstance(entry, dict): raise serializers.ValidationError(f"extractors[{i}] must be an object") @@ -325,8 +332,6 @@ def validate_extractors(self, raw): if not ser.is_valid(): raise serializers.ValidationError({f"extractors[{i}]": ser.errors}) validated.append(ser.validated_data) - compiled[ser.validated_data["name"]] = ser.compiled - self.compiled = compiled return validated def validate(self, data): diff --git a/backend/agent_kv/tests/test_submit_serializer.py b/backend/agent_kv/tests/test_submit_serializer.py index 027ee3f16c..e7037e39c0 100644 --- a/backend/agent_kv/tests/test_submit_serializer.py +++ b/backend/agent_kv/tests/test_submit_serializer.py @@ -16,6 +16,7 @@ from unittest import mock import django +import pytest from django.apps import apps os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") @@ -23,8 +24,10 @@ django.setup() from django.core.files.uploadedfile import SimpleUploadedFile # noqa: E402 +from rest_framework import serializers # noqa: E402 from agent_kv.execution_serializers import ( # noqa: E402 + ExtractorSerializer, KVOptionsSerializer, SubmitSerializer, ) @@ -476,3 +479,47 @@ def test_table_keys_are_validated_through_the_keys_serializer(): assert not s.is_valid() assert "unknown keys for extractor 'table'" in _errs(s) + + +# --------------------------------------------------------------------------- +# 2.20: `compile_schema` is called in the no-keys-serializer fall-through for +# what it REFUSES, not for what it returns. +# +# The `CompiledSchema` used to be stashed on `ExtractorSerializer.compiled` and +# collected into `SubmitSerializer.compiled`, which no non-test code read -- +# `dispatch_job` sends the raw `keys` dict and the engine recompiles. Deleting +# the attribute must not quietly delete the 400, which is the only reason the +# call is there. +# +# The branch is DORMANT on this deployment: `kv` is the only extractor without +# a keys serializer and `validate_name` refuses it before `_validated_keys` +# runs, so it is reached by calling it directly. That is the point -- it is the +# contract for the next extractor added without one, and nothing else covers it. +# --------------------------------------------------------------------------- + + +def test_the_schema_compiler_fallthrough_still_rejects_a_bad_schema(): + ser = ExtractorSerializer() + + with pytest.raises(serializers.ValidationError) as exc: + # A leaf with no `description` is refused by the ported compiler. + ser._validated_keys("some_future_extractor", {"total": {}}) + + assert "keys" in exc.value.detail + + +def test_the_schema_compiler_fallthrough_returns_the_raw_spec(): + """What reaches `dispatch_job` is the submitted dict, unchanged.""" + ser = ExtractorSerializer() + spec = {"total": {"description": "The grand total", "format": "currency"}} + + assert ser._validated_keys("some_future_extractor", spec) is spec + + +def test_neither_serializer_still_carries_a_compiled_attribute(): + """A `compiled` that is populated and never read reads as plumbing.""" + for cls in (ExtractorSerializer, SubmitSerializer): + assert not hasattr(cls, "compiled"), ( + f"{cls.__name__}.compiled was dead: `dispatch_job` sends the raw " + "keys dict, and the compiled form cannot cross the queue anyway" + ) diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py index b9d7ba70ba..cf61f061c0 100644 --- a/backend/agent_kv/tests/test_submit_view.py +++ b/backend/agent_kv/tests/test_submit_view.py @@ -248,9 +248,13 @@ def _side_effect(job, *, extractor, schema, options): # --------------------------------------------------------------------------- # The 9-key options dict SubmitView.post builds and forwards to dispatch_job -# must map every field correctly and carry the compiled schema -- a -# dropped/typo'd key here would otherwise pass silently since nothing else -# asserts on dispatch_job's call args. +# must map every field correctly -- a dropped/typo'd key here would otherwise +# pass silently since nothing else asserts on dispatch_job's call args. +# +# It carries the RAW `keys` dict, not a `CompiledSchema`. The serializer used +# to stash the compiled form and nothing read it; the engine recompiles from +# the raw dict by design (see `compile.py`'s docstring), and the compiled form +# could not cross the queue anyway. # --------------------------------------------------------------------------- @mock.patch.object(ev, "dispatch_job") @mock.patch.object(AgentKVJob, "save", autospec=True) From a6df38bc0251814b0752756c4e5607e220db11b9 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 7 Oct 2026 11:04:33 +0530 Subject: [PATCH 15/54] UN-4232 [MISC] Clear the eight SonarCloud findings on #2317 - **S3776** `SubmitView.post` at cognitive complexity 20 against a limit of 15. Extracted the two self-contained blocks as module-level helpers next to the ones already there (`_subscription_denial`, `_dispatch_or_fail`, `_sync_wait_response`): `_request_data_with_extractors_inlined` (the bounded file-part read) and `_discard_orphaned_input` (the staged-object cleanup for a submit that failed before its row landed). Behaviour unchanged; the rationale comments moved with the code they explain rather than being left behind in `post`. - **S5778 x3** in `test_compile.py`: `pytest.raises` blocks containing two calls, because the spec was built inside them. Hoisted `_one_key(fmt)` out, so what the block asserts about is `compile_schema` alone. - **S5799 x2**: implicitly concatenated string literals in `dispatch.py` and `agent_kv_tasks.py` -- ruff-format artefacts that landed as `"... not " "enqueueing"` on one line, which is exactly the shape of a forgotten comma. Merged; both stay inside 90 columns. - **S117 x2** in migration 0004: `PgPeriodicTask = apps.get_model(...)` is the standard Django idiom but is a local variable, so S117 reads the CamelCase as a naming violation. Renamed to `periodic_task` with a comment saying why it departs from the idiom, so the next person does not "fix" it back. Cloud #1828 has zero SonarCloud findings. 254 backend tests, 128 schema-package tests, 23 ide_callback tests, all green. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/dispatch.py | 2 +- backend/agent_kv/execution_views.py | 132 ++++++++++-------- .../migrations/0004_pg_periodic_tasks.py | 11 +- .../agent-kv-schema/tests/test_compile.py | 9 +- workers/ide_callback/agent_kv_tasks.py | 2 +- 5 files changed, 87 insertions(+), 69 deletions(-) diff --git a/backend/agent_kv/dispatch.py b/backend/agent_kv/dispatch.py index de29cf50f6..149a56ab3a 100644 --- a/backend/agent_kv/dispatch.py +++ b/backend/agent_kv/dispatch.py @@ -138,7 +138,7 @@ def dispatch_job(job, *, extractor: str, schema: dict, options: dict) -> None: id=job.id, status__in=list(AgentKVJob.TERMINAL) ).exists(): logger.info( - "agent-kv: job %s was terminalized before dispatch; not " "enqueueing", + "agent-kv: job %s was terminalized before dispatch; not enqueueing", job.id, ) return diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index 262888fe0c..556deb5385 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -212,6 +212,76 @@ def _sync_wait_response(job, wait: float) -> Response | None: return None +def _request_data_with_extractors_inlined(request): + """A mutable copy of the request data with a file-part `extractors` read in. + + Split out of ``SubmitView.post`` for sonar S3776 (the method was at + cognitive complexity 20 against a limit of 15); the behaviour is unchanged. + + Bounded read, which is the point. The size cap lives in + ``validate_extractors``, i.e. AFTER this point -- and + ``DATA_UPLOAD_MAX_MEMORY_SIZE`` excludes file-typed parts, so a 500 MB + `extractors` part was materialised in full (plus up to 4x that again for + the ``str``) before being rejected at 256 KiB. One such request per worker + process OOMs the pod, which makes it a cheap denial of service. + """ + data = request.data.copy() + part = data.get("extractors") + if not hasattr(part, "read"): # not a file part (§7.1); nothing to inline + return data + # Read one byte past the cap: enough to know it is over without ever + # holding more than the cap plus one. + limit = settings.AGENT_KV_MAX_SCHEMA_BYTES + raw = part.read(limit + 1) + if len(raw) > limit: + raise ValidationError({"extractors": f"extractors payload exceeds {limit} bytes"}) + try: + # `errors="strict"`, not "replace". A latin-1 key name used to decode + # to U+FFFD and then compile cleanly, so a malformed payload became a + # job that ran against a schema the caller never wrote. + data["extractors"] = raw.decode("utf-8") + except UnicodeDecodeError: + raise ValidationError({"extractors": "extractors must be valid UTF-8"}) from None + return data + + +def _discard_orphaned_input(job) -> None: + """Delete the staged object for a submit that failed before the row landed. + + Split out of ``SubmitView.post`` for sonar S3776; behaviour unchanged. + + If ``stage_input`` succeeded and ``job.save()`` then raised, the upload + exists with no row to carry its ref -- and ``run_ttl_cleanup`` selects + candidates from ``AgentKVJob`` rows, so an object with no row is + structurally unreachable by every cleanup path there is. It would sit in + the bucket forever, holding customer data nobody can find or delete. + """ + if not job.input_ref: + return + # `delete_job_files` reports which refs are confirmed gone and swallows the + # rest -- normally the ref survives as the retry handle. Here there is no + # row to hold it, so a delete that failed means the object is orphaned with + # nothing anywhere pointing at it. Log the ref itself: it is the only way + # anyone can clean it up by hand. + try: + cleared = delete_job_files(job) + except Exception: + cleared = [] + logger.exception( + "agent-kv: staged-input cleanup raised for failed submit %s (ref=%s)", + job.id, + job.input_ref, + ) + if "input_ref" not in cleared: + logger.error( + "agent-kv: the staged input for failed submit %s could NOT be " + "removed and no job row exists to carry its ref -- object %s is " + "orphaned and unreachable by TTL cleanup; remove it manually", + job.id, + job.input_ref, + ) + + class SubmitView(APIView): authentication_classes: list = [] permission_classes: list = [] @@ -228,34 +298,7 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): if denied is not None: return denied - data = request.data.copy() - part = data.get("extractors") - if hasattr(part, "read"): # `extractors` uploaded as a file part (§7.1) - # Bounded read. The size cap lives in `validate_extractors`, i.e. - # AFTER this point -- and `DATA_UPLOAD_MAX_MEMORY_SIZE` excludes - # file-typed parts, so a 500 MB `extractors` part was materialised - # in full (plus up to 4x that again for the `str`) before being - # rejected at 256 KiB. One such request per worker process OOMs the - # pod, which makes it a cheap denial of service. - # - # Read one byte past the cap: enough to know it is over without - # ever holding more than the cap plus one. - limit = settings.AGENT_KV_MAX_SCHEMA_BYTES - raw = part.read(limit + 1) - if len(raw) > limit: - raise ValidationError( - {"extractors": f"extractors payload exceeds {limit} bytes"} - ) - try: - # `errors="strict"`, not "replace". A latin-1 key name used to - # decode to U+FFFD and then compile cleanly, so a malformed - # payload became a job that ran against a schema the caller - # never wrote. - data["extractors"] = raw.decode("utf-8") - except UnicodeDecodeError: - raise ValidationError( - {"extractors": "extractors must be valid UTF-8"} - ) from None + data = _request_data_with_extractors_inlined(request) serializer = SubmitSerializer(data=data) serializer.is_valid(raise_exception=True) v = serializer.validated_data @@ -281,38 +324,7 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): job_saved = True except Exception: logger.exception("agent-kv staging/save failed for job %s", job.id) - # Delete the staged object before responding. If `stage_input` - # succeeded and `job.save()` then raised, the upload exists with no - # row to carry its ref -- and `run_ttl_cleanup` selects candidates - # from `AgentKVJob` rows, so an object with no row is structurally - # unreachable by every cleanup path there is. It would sit in the - # bucket forever, holding customer data nobody can find or delete. - if job.input_ref: - # `delete_job_files` reports which refs are confirmed gone and - # swallows the rest -- normally the ref survives as the retry - # handle. Here there is no row to hold it, so a delete that - # failed means the object is orphaned with nothing anywhere - # pointing at it. Log the ref itself: it is the only way anyone - # can clean it up by hand. - try: - cleared = delete_job_files(job) - except Exception: - cleared = [] - logger.exception( - "agent-kv: staged-input cleanup raised for failed " - "submit %s (ref=%s)", - job.id, - job.input_ref, - ) - if "input_ref" not in cleared: - logger.error( - "agent-kv: the staged input for failed submit %s could " - "NOT be removed and no job row exists to carry its ref " - "-- object %s is orphaned and unreachable by TTL " - "cleanup; remove it manually", - job.id, - job.input_ref, - ) + _discard_orphaned_input(job) return _fail_job_response( job, org_id, diff --git a/backend/agent_kv/migrations/0004_pg_periodic_tasks.py b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py index 5a0cb7f8e7..046f7558e7 100644 --- a/backend/agent_kv/migrations/0004_pg_periodic_tasks.py +++ b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py @@ -57,9 +57,12 @@ def create_pg_periodic_tasks(apps, schema_editor): - PgPeriodicTask = apps.get_model("pg_queue", "PgPeriodicTask") + # snake_case, not the usual `PgPeriodicTask = apps.get_model(...)` Django + # idiom: it is a local variable, and sonar's S117 reads the CamelCase name + # as a naming violation. The historical-model object is the same either way. + periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") for spec in PG_PERIODIC_TASKS: - PgPeriodicTask.objects.update_or_create( + periodic_task.objects.update_or_create( name=spec["name"], defaults={ "task_name": spec["task_name"], @@ -79,8 +82,8 @@ def create_pg_periodic_tasks(apps, schema_editor): def remove_pg_periodic_tasks(apps, schema_editor): - PgPeriodicTask = apps.get_model("pg_queue", "PgPeriodicTask") - PgPeriodicTask.objects.filter( + periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") + periodic_task.objects.filter( name__in=[spec["name"] for spec in PG_PERIODIC_TASKS] ).delete() diff --git a/unstract/agent-kv-schema/tests/test_compile.py b/unstract/agent-kv-schema/tests/test_compile.py index 12a3a707d4..c21e61fc71 100644 --- a/unstract/agent-kv-schema/tests/test_compile.py +++ b/unstract/agent-kv-schema/tests/test_compile.py @@ -284,8 +284,9 @@ def test_a_case_variant_of_a_known_format_is_refused(fmt, intended): every value for the life of the job, and `/validate` reported `{"valid": true}` -- the author configured validation and got none. """ + spec = _one_key(fmt) with pytest.raises(SchemaError, match=f"Did you mean '{intended}'"): - compile_schema(_one_key(fmt)) + compile_schema(spec) @pytest.mark.parametrize("fmt", ["number", "date", "currency", "string"]) @@ -312,8 +313,9 @@ def test_an_enum_with_no_values_is_refused(fmt): so every non-empty value fails QA forever. This does not disable validation, it inverts it. """ + spec = _one_key(fmt) with pytest.raises(SchemaError, match="enum with no values"): - compile_schema(_one_key(fmt)) + compile_schema(spec) def test_an_enum_with_values_still_compiles(): @@ -323,8 +325,9 @@ def test_an_enum_with_values_still_compiles(): @pytest.mark.parametrize("fmt", ["regex:", "regex"]) def test_an_empty_regex_is_refused(fmt): + spec = _one_key(fmt) with pytest.raises(SchemaError, match="empty regex"): - compile_schema(_one_key(fmt)) + compile_schema(spec) def test_the_same_rules_apply_to_array_columns(): diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index 4b13142892..5b56d3d393 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -246,7 +246,7 @@ def agent_kv_cancelled( # exists to deliver -- and nothing retried or recorded it. Raise so the # retry budget applies, exactly as a transport exception would. logger.error( - "agent_kv_cancelled: webhook not delivered for job %s (retry %s of " "%s)", + "agent_kv_cancelled: webhook not delivered for job %s (retry %s of %s)", job_id, self.request.retries, self.max_retries, From d068e58cd4381184c4fb9b0941c121ea7ae3e58d Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 7 Oct 2026 10:33:28 +0530 Subject: [PATCH 16/54] UN-4232 [FEAT] The table extractor runs on the caller's adapters, not operator env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses spec D6 for the `table` extractor. D6 specified "no platform adapters by design" -- models and OCR credentials in operator env vars, "no end-user model control". That was right while the consumer was hypothetical and wrong once the consumers are existing customers with accounts: * There was NO operator-level LLM credential anywhere in the platform to reuse. LLM credentials live in per-org `AdapterInstance` rows, encrypted; the only LLMWhisperer key in a namespace is `LLMWHISPERER_PORTAL_KEY`, which is the portal ADMIN key that mints per-org extraction keys -- wrong credential, and over-privileged for executor pods. So the six required env vars had no source, and a deployment could not be completed. * Env configuration put every customer's model choice and every customer's LLM spend on one shared operator key. * The IDE table path in the SAME plugin already resolves `llm` / `lite_llm` / `x2text` from adapter instances. This makes the two paths agree. Wire format follows the platform's own conventions rather than inventing one: * `adapters: {llm, lite_llm, x2text}` -- a SIBLING of `keys`/`options`, named by ROLE. `prompt_profile_manager_v2.ProfileManager` declares `llm` / `x2text` / `embedding_model` / `vector_store`, as fields on the owning object rather than inside a knobs blob, so neither `*_adapter_id` naming nor burying them in `options` would have matched. Keeping them out of `options` also leaves that member optional. * Types validated against `AdapterTypes` (`unstract/sdk1/constants.py`). * Errors are DRF `ValidationError`s -> 400, like the rest of the submit caps. **Validation is split, and the split is the point.** Shape (`_validated_adapter_shape`) is in the serializer and touches no database -- `test_submit_serializer.py` is a unit suite, and my first attempt put an `AdapterInstance` lookup there and reddened 22 tests. Ownership and type (`_resolved_adapters`) are in the VIEW, where the Bearer key is, for the same reason `_subscription_denial` lives there. Both run before the job row, the concurrency slot and the staging write -- the first version of the view gate sat AFTER staging while its own comment claimed otherwise. **The org filter is the tenant boundary and nothing downstream repeats it.** The executor resolves these ids through the platform service and does not re-check whose they are, so without `AdapterInstance.objects.filter(id=..., organization_id=key.organization_id)` a caller names any adapter UUID and the run spends ANOTHER TENANT'S LLM credential. Same class as the `organization_id` filter in `_get_job`, higher consequence. No platform helper fitted: `AdapterProcessor.get_adapters_by_type` filters `.for_user(user)` and this path has no user (a key resolves to an ORGANIZATION), and `get_adapter_by_name_and_type` is unscoped. Three deliberate choices in that gate, each documented at the code: * "No such adapter" and "not yours" return the IDENTICAL message, with a test asserting the bodies are byte-equal -- distinguishing them makes the endpoint an oracle for which adapter ids exist in other organizations. * Type is checked because an `X2TEXT` id in the `llm` slot resolves fine and then fails deep in the engine as a provider error, which reads like a broken model rather than two swapped UUIDs. * Per-user adapter visibility is NOT honoured. `AdapterInstance` is `HasMembersMixin` with `ResourceMembership` VIEWER rows, and an org-scoped key has no user to evaluate them against -- so a key reaches any adapter in its own org. A key is already an organization-level credential, so this is a decision, not an oversight. `kv` is untouched and stays env-configured: one credential model per extractor, never both for one extractor. `ExtractionConfig` keeps its credential fields for it. Docs: new §3a covering the three roles, what each is for (lite_llm runs once per page, so it is the cost-critical one), the four 400 cases, and the D6 reversal with its consequences stated -- the API is no longer callable by someone who has never signed in, and a table-only deployment can leave `global.sharedConfigs.agentKv.enabled: false` entirely. Added a `table` curl example; the existing `kv` one is now labelled as wire-format-only, since this build 400s it. 259 backend tests (was 254). Mutation-checked: removing the `organization_id` filter fails 4. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/dispatch.py | 16 +- backend/agent_kv/execution_serializers.py | 104 ++++++++- backend/agent_kv/execution_views.py | 84 ++++++- .../agent_kv/tests/test_submit_serializer.py | 31 ++- backend/agent_kv/tests/test_submit_view.py | 213 +++++++++++++++++- docs/agent-kv-api.md | 94 +++++++- 6 files changed, 522 insertions(+), 20 deletions(-) diff --git a/backend/agent_kv/dispatch.py b/backend/agent_kv/dispatch.py index 149a56ab3a..f45dcb3827 100644 --- a/backend/agent_kv/dispatch.py +++ b/backend/agent_kv/dispatch.py @@ -94,7 +94,9 @@ def _platform_api_key(job) -> str: return str(platform_key.key) -def dispatch_job(job, *, extractor: str, schema: dict, options: dict) -> None: +def dispatch_job( + job, *, extractor: str, schema: dict, options: dict, adapters: dict | None = None +) -> None: executor_name, operation = EXTRACTOR_ROUTES[extractor] org_id = str(job.organization_id) # Everything that can fail — platform-key lookup, context construction, @@ -115,6 +117,18 @@ def dispatch_job(job, *, extractor: str, schema: dict, options: dict) -> None: "input_ref": job.input_ref, "schema": schema, "options": options, + # Platform adapter instance ids, by role, already validated at + # submit against THIS job's organization and against the + # expected `AdapterTypes` (see + # `execution_serializers.validated_adapters`). The executor + # resolves them through the platform service using the key + # below -- it does NOT re-check tenancy, so the submit-time + # check is the only one there is. + # + # Empty for an env-configured extractor (`kv`), which is why + # this is a dict rather than three params: the two credential + # models coexist, one per extractor. + "adapters": adapters or {}, "platform_api_key": _platform_api_key(job), # The CAP the engine must enforce (spec §6.1/§6.6), not the # measured count -- job.pages_total is None for Excel (no diff --git a/backend/agent_kv/execution_serializers.py b/backend/agent_kv/execution_serializers.py index 6dada100d0..01064c3c65 100644 --- a/backend/agent_kv/execution_serializers.py +++ b/backend/agent_kv/execution_serializers.py @@ -8,6 +8,7 @@ from agent_kv.constants import EXTRACTOR_ROUTES, TABLE_EXTRACTOR_NAME, V1_EXTRACTOR_NAME from unstract.agent_kv_schema import SchemaError, compile_schema +from unstract.sdk1.constants import AdapterTypes # Images are deliberately ABSENT, and that is a cross-repo contract, not an # oversight. The cloud engine's `_build_agent_graph` (agentic_kv @@ -154,6 +155,63 @@ def validate(self, data): TABLE_EXTRACTOR_NAME: TableOptionsSerializer, } + +class TableAdaptersSerializer(serializers.Serializer): + """The platform adapters the `table` extractor runs on. + + **Why the caller names adapters rather than the operator configuring env + vars.** The engine needs two LLMs and an OCR source. The IDE table path + resolves all three from platform adapter instances the user configured + (`agentic_table/executor.py` -> `LLM(adapter_instance_id=...)`, + `X2Text(adapter_instance_id=...)`), and this API now does the same. That + reverses spec D6, which specified system-level env configuration and "no + end-user model control" -- a deliberate reversal, recorded here and in + `docs/agent-kv-api.md`: the consumers are existing customers with + accounts, so they already own adapters, and letting them choose puts model + selection and LLM spend on the account that benefits from it. + + Field names follow the platform convention for naming adapters by ROLE, + not by id: `prompt_profile_manager_v2.ProfileManager` declares `llm`, + `x2text`, `embedding_model`, `vector_store`. Hence `llm` / `lite_llm` / + `x2text` rather than `llm_adapter_id` and friends. + + `lite_llm` is separate because the engine uses a cheaper model for + per-page presence detection and the advanced one for structure and + extraction; collapsing them would silently multiply the cost of the + highest-volume stage. + """ + + llm = serializers.UUIDField() + lite_llm = serializers.UUIDField() + x2text = serializers.UUIDField() + + def validate(self, data): + unknown = set(self.initial_data) - set(self.fields) + if unknown: + raise serializers.ValidationError( + f"unknown adapters for extractor '{TABLE_EXTRACTOR_NAME}': " + f"{sorted(unknown)}" + ) + return data + + +#: Each extractor's own `adapters` validator, and which `AdapterTypes` each +#: role must be. An extractor absent from this table takes no adapters -- `kv` +#: is env-configured (`extraction_seams.ExtractionConfig`) and deliberately +#: stays that way for now, so the two credential models coexist: one extractor +#: per model, never both for one extractor. +_ADAPTERS_SERIALIZERS = { + TABLE_EXTRACTOR_NAME: ( + TableAdaptersSerializer, + { + "llm": AdapterTypes.LLM, + "lite_llm": AdapterTypes.LLM, + "x2text": AdapterTypes.X2TEXT, + }, + ), +} + + #: Each extractor's own `keys` validator. An extractor absent from this table #: falls through to the schema compiler, which is `kv`'s contract: its `keys` IS #: a compiled schema rather than a fixed set of fields. @@ -162,11 +220,46 @@ def validate(self, data): } +def _validated_adapter_shape(name: str, supplied) -> dict: + """Validate an extractor's `adapters` block SHAPE only. No database. + + Returns `{role: ""}`, or `{}` for an extractor that takes none. + + Deliberately split from the tenancy check. This runs in the serializer, so + it must stay free of the ORM -- `test_submit_serializer.py` is a unit suite + with no database, and an adapter lookup here would make every submit test + require one. What it does check is everything that needs no database: + presence, that each id parses as a UUID, and that no unknown role was sent. + + **Ownership and type are checked in the VIEW** + (`execution_views._resolved_adapters`), because they need the Bearer key's + organization -- the same reason `_subscription_denial` lives there. Both + still run before anything is staged or billed. + """ + entry = _ADAPTERS_SERIALIZERS.get(name) + if entry is None: + if supplied: + raise serializers.ValidationError( + {"adapters": f"extractor '{name}' takes no adapters"} + ) + return {} + + adapters_cls, _expected_types = entry + ser = adapters_cls(data=supplied if isinstance(supplied, dict) else {}) + ser.is_valid(raise_exception=True) + return {role: str(value) for role, value in ser.validated_data.items()} + + class ExtractorSerializer(serializers.Serializer): """One entry of the submit's `extractors` array (spec §7.0/§7.1).""" name = serializers.CharField() keys = serializers.JSONField() + #: Sibling of `keys`/`options`, not nested inside them. These are the + #: RESOURCES the extractor runs on, which `ProfileManager` likewise holds + #: as fields on the owning object rather than inside a knobs blob -- and + #: keeping them out of `options` leaves that member optional. + adapters = serializers.DictField(required=False, default=dict) options = serializers.DictField(required=False, default=dict) def validate_name(self, v): @@ -331,7 +424,16 @@ def validate_extractors(self, raw): ser = ExtractorSerializer(data=entry) if not ser.is_valid(): raise serializers.ValidationError({f"extractors[{i}]": ser.errors}) - validated.append(ser.validated_data) + data = ser.validated_data + try: + data["adapters"] = _validated_adapter_shape( + data["name"], data.get("adapters") + ) + except serializers.ValidationError as e: + raise serializers.ValidationError( + {f"extractors[{i}]": e.detail} + ) from None + validated.append(data) return validated def validate(self, data): diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index 556deb5385..52479e3b20 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -17,7 +17,7 @@ RateLimited, SubscriptionGateUnavailable, ) -from agent_kv.execution_serializers import SubmitSerializer +from agent_kv.execution_serializers import _ADAPTERS_SERIALIZERS, SubmitSerializer from agent_kv.execution_views_result import result_payload from agent_kv.key_validator import AgentKVKeyValidator from agent_kv.models import AgentKVJob, JobStatus @@ -181,7 +181,13 @@ def _dispatch_or_fail(job, org_id: str, entry: dict, options: dict) -> Response escape as an unhandled 500 with the slot still held. """ try: - dispatch_job(job, extractor=entry["name"], schema=entry["keys"], options=options) + dispatch_job( + job, + extractor=entry["name"], + schema=entry["keys"], + options=options, + adapters=entry.get("adapters") or {}, + ) except DispatchError: logger.exception("agent-kv dispatch failed for job %s", job.id) except Exception: @@ -282,6 +288,73 @@ def _discard_orphaned_input(job) -> None: ) +def _resolved_adapters(entry: dict, agent_kv_key) -> dict: + """Confirm every adapter the caller named is THIS org's, and of the right type. + + Returns `{role: ""}` unchanged on success -- the ids are already + UUID-shaped and role-checked by the serializer + (`_validated_adapter_shape`); what is added here is the half that needs a + database and the Bearer key. + + **This is the tenant boundary on adapters, and nothing downstream repeats + it.** The executor resolves these ids through the platform service with an + org-scoped platform key, but it does not re-check that the id belongs to + the job's org -- so without this, a caller passes any adapter UUID and the + run SPENDS ANOTHER TENANT'S LLM CREDENTIAL. Same class as the + `organization_id` filter in `_get_job`, and the highest-consequence failure + this endpoint has. + + It lives in the view rather than the serializer for the same reason + `_subscription_denial` does: it needs the key. The platform's own helpers + do not fit -- `AdapterProcessor.get_adapters_by_type` filters + `.for_user(user)` and this path has no user (a key resolves to an + ORGANIZATION), and `get_adapter_by_name_and_type` is not scoped at all. + + TYPE is checked too: an `X2TEXT` id accepted into the `llm` slot resolves + fine and then fails deep in the engine as a provider error, which reads + like a broken model rather than two swapped UUIDs. + + What this deliberately does NOT honour is per-user adapter visibility + (`AdapterInstance` is `HasMembersMixin`, with `ResourceMembership` VIEWER + rows). An org-scoped key has no user to evaluate it against, so a key may + use ANY adapter in its own organization. Deliberate: a key is an + organization-level credential, and whoever can mint one can already read + the org's data. + + Raises: + ValidationError: 400 at submit -- before staging, before a slot is + taken, before anything is billed. + """ + requested = entry.get("adapters") or {} + if not requested: + return {} + + from adapter_processor_v2.models import AdapterInstance + + _, expected_types = _ADAPTERS_SERIALIZERS[entry["name"]] + for role, adapter_id in requested.items(): + expected = expected_types[role] + adapter = AdapterInstance.objects.filter( + id=adapter_id, organization_id=agent_kv_key.organization_id + ).first() + if adapter is None: + # One message for "no such adapter" and "not yours", on purpose: + # telling them apart reveals which UUIDs exist. + raise ValidationError( + {"adapters": f"{role}: no such adapter in this organization"} + ) + if adapter.adapter_type != expected.value: + raise ValidationError( + { + "adapters": ( + f"{role}: adapter is of type '{adapter.adapter_type}', " + f"expected '{expected.value}'" + ) + } + ) + return requested + + class SubmitView(APIView): authentication_classes: list = [] permission_classes: list = [] @@ -304,6 +377,13 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): v = serializer.validated_data org_id = str(agent_kv_key.organization_id) + # Tenancy + type on the named adapters, HERE and not later: before the + # job row, before the concurrency slot, before the staging write. A + # caller who names another org's adapter, or swaps two UUIDs, gets a + # 400 and is charged nothing -- the same "every cap before paid work" + # rule the serializer's caps follow. + _resolved_adapters(v["extractors"][0], agent_kv_key) + job = AgentKVJob( api_key=agent_kv_key, organization_id=agent_kv_key.organization_id, diff --git a/backend/agent_kv/tests/test_submit_serializer.py b/backend/agent_kv/tests/test_submit_serializer.py index e7037e39c0..a94d77dcfb 100644 --- a/backend/agent_kv/tests/test_submit_serializer.py +++ b/backend/agent_kv/tests/test_submit_serializer.py @@ -58,13 +58,36 @@ def _pdf_upload(name="doc.pdf"): } +#: The platform adapters a `table` submit must name. All three are REQUIRED -- +#: the engine needs two LLMs and an OCR source, and this API resolves them from +#: the caller's own adapter instances rather than from operator env vars. +#: Ownership and type are checked in the VIEW against the Bearer key's org +#: (`execution_views._resolved_adapters`); the serializer only checks shape, so +#: these can be any UUIDs here. +TABLE_ADAPTERS = { + "llm": "11111111-1111-1111-1111-111111111111", + "lite_llm": "22222222-2222-2222-2222-222222222222", + "x2text": "33333333-3333-3333-3333-333333333333", +} + + def _data(**over): """Build a submit payload in the extractor-scoped wire format (§7.0).""" keys = over.pop("keys", TABLE_KEYS) + adapters = over.pop("adapters", TABLE_ADAPTERS) options = {k: over.pop(k) for k in list(over) if k not in _JOB_LEVEL} d = { "file": _pdf_upload(), - "extractors": json.dumps([{"name": "table", "keys": keys, "options": options}]), + "extractors": json.dumps( + [ + { + "name": "table", + "keys": keys, + "adapters": adapters, + "options": options, + } + ] + ), } d.update(over) return d @@ -458,7 +481,11 @@ def test_a_padded_name_is_still_normalised_for_routing(): identity twice, not accepting whitespace. """ s = SubmitSerializer( - data=_data(extractors=json.dumps([{"name": " table ", "keys": TABLE_KEYS}])) + data=_data( + extractors=json.dumps( + [{"name": " table ", "keys": TABLE_KEYS, "adapters": TABLE_ADAPTERS}] + ) + ) ) assert s.is_valid(), s.errors diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py index cf61f061c0..429ac44de6 100644 --- a/backend/agent_kv/tests/test_submit_view.py +++ b/backend/agent_kv/tests/test_submit_view.py @@ -3,6 +3,7 @@ from unittest import mock import django +import pytest from django.apps import apps os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") @@ -67,6 +68,13 @@ def _valid_validated_data(**overrides): # code they name. It also meant the whole file exercised the view with an # `extractors` payload the real serializer rejects with a 400. keys = overrides.pop("keys", {"target_table": "Rent roll"}) + # EXPLICIT, and empty by default. `_resolved_adapters` reads + # `entry.get("adapters")` and returns early when it is falsy -- a MagicMock + # auto-attribute here would be TRUTHY, so the gate would fall through to a + # real `AdapterInstance` query and every view test would die on "Database + # access not allowed". Tests that exercise the gate set it and mock the + # lookup; the rest leave it empty and never touch the ORM. + adapters = overrides.pop("adapters", {}) options = { "instructions": "", "json_structure": "", @@ -77,7 +85,9 @@ def _valid_validated_data(**overrides): options.update({k: overrides.pop(k) for k in list(overrides) if k not in _JOB_LEVEL}) data = { "file": mock.Mock(name="uploaded_file"), - "extractors": [{"name": "table", "keys": keys, "options": options}], + "extractors": [ + {"name": "table", "keys": keys, "adapters": adapters, "options": options} + ], "page_start": 1, "page_end": None, "timeout": 0, @@ -97,6 +107,47 @@ def _mock_serializer(m_cls, **overrides): return instance +#: A `table` submit's three required adapter roles. +_TABLE_ADAPTERS = { + "llm": "11111111-1111-1111-1111-111111111111", + "lite_llm": "22222222-2222-2222-2222-222222222222", + "x2text": "33333333-3333-3333-3333-333333333333", +} + +#: Which `AdapterTypes` value each role must resolve to. +_ROLE_TYPES = {"llm": "LLM", "lite_llm": "LLM", "x2text": "X2TEXT"} + + +def _adapters_owned_by(organization_id, *, types=None, missing=()): + """Patch the adapter lookup `_resolved_adapters` performs. + + The gate runs `AdapterInstance.objects.filter(id=..., organization_id=...) + .first()`, so this stands in for the ORM: an id in `missing` resolves to + None (not this org's, or nonexistent -- the gate cannot and must not tell + those apart), and everything else resolves to an adapter of the type + `types` gives, defaulting to the correct one for its role. + """ + from adapter_processor_v2.models import AdapterInstance + + type_by_id = { + aid: (types or {}).get(role, _ROLE_TYPES[role]) + for role, aid in _TABLE_ADAPTERS.items() + } + + def _filter(**kw): + qs = mock.Mock() + aid = str(kw.get("id")) + if aid in {str(m) for m in missing} or kw.get( + "organization_id" + ) != organization_id: + qs.first.return_value = None + else: + qs.first.return_value = mock.Mock(adapter_type=type_by_id.get(aid, "LLM")) + return qs + + return mock.patch.object(AdapterInstance, "objects", **{"filter.side_effect": _filter}) + + def _stamp_created_at(job, *args, **kwargs): """save() side effect mimicking auto_now_add for a fully-mocked save(). @@ -222,7 +273,7 @@ def test_happy_path_returns_202_with_job_id_status_and_status_url( m_limiter.check_and_acquire.return_value = True m_save.side_effect = _stamp_created_at - def _side_effect(job, *, extractor, schema, options): + def _side_effect(job, *, extractor, schema, options, adapters=None): job.status = JobStatus.DISPATCHED job.dispatched_at = timezone.now() @@ -719,21 +770,27 @@ def test_real_serializer_through_real_view_reaches_dispatch_intact_for_table( m_save.side_effect = _stamp_created_at schema = {"target_table": "Rent rolls"} - resp = ev.SubmitView.as_view()( - _real_multipart_post( - [ - { - "name": "table", - "keys": schema, - "options": {"instructions": "skip totals"}, - } - ], + with _adapters_owned_by(kv_key().organization_id): + resp = ev.SubmitView.as_view()( + _real_multipart_post( + [ + { + "name": "table", + "keys": schema, + "adapters": _TABLE_ADAPTERS, + "options": {"instructions": "skip totals"}, + } + ], + ) ) - ) assert resp.status_code == 202, resp.data kwargs = m_dispatch.call_args.kwargs assert kwargs["extractor"] == "table" + # The caller's own adapter instances reach the executor, by role. This is + # the whole point of the adapter wire format: the engine resolves these + # through the platform service instead of reading operator env vars. + assert kwargs["adapters"] == _TABLE_ADAPTERS # Nested under `schema`, matching exactly what the cloud executor reads # (`params["schema"]["target_table"]") -- NOT hoisted to the top level. assert kwargs["schema"] == {"target_table": "Rent rolls"} @@ -883,3 +940,135 @@ def test_a_save_failure_after_staging_removes_the_staged_object( "the staged upload was left in the bucket with no job row carrying its " "ref; TTL cleanup selects from job rows, so it can never be reached" ) + + +# --------------------------------------------------------------------------- +# The adapter tenancy gate. +# +# `POST /agent-kv/` names platform adapters by id, and the executor resolves +# them through the platform service WITHOUT re-checking whose they are. So the +# submit-time org filter is the only thing standing between a caller and +# another tenant's LLM credential. Same class as the `organization_id` filter +# on every job-scoped view, and higher consequence: that one leaks a job, this +# one spends someone else's money. +# --------------------------------------------------------------------------- + + +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save", autospec=True) +@mock.patch.object(ev, "stage_input") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) +@mock.patch.object(AgentKVKey, "objects") +@pytest.mark.parametrize("stolen_role", ["llm", "lite_llm", "x2text"]) +def test_an_adapter_from_another_org_is_refused( + m_keys, m_plugin, m_rate, m_limiter, m_stage, m_save, m_dispatch, stolen_role +): + """Parametrised across all three roles: one unguarded slot is enough.""" + key = kv_key() + m_keys.get.return_value = key + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + + with _adapters_owned_by( + key.organization_id, missing=[_TABLE_ADAPTERS[stolen_role]] + ): + resp = ev.SubmitView.as_view()( + _real_multipart_post( + [ + { + "name": "table", + "keys": {"target_table": "Rent rolls"}, + "adapters": _TABLE_ADAPTERS, + "options": {}, + } + ], + ) + ) + + assert resp.status_code == 400, resp.data + assert "no such adapter in this organization" in str(resp.data) + assert stolen_role in str(resp.data) + assert not m_dispatch.called, "an unowned adapter must never reach the executor" + assert not m_stage.called, "and nothing may be staged or billed first" + + +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save", autospec=True) +@mock.patch.object(ev, "stage_input") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) +@mock.patch.object(AgentKVKey, "objects") +def test_an_adapter_of_the_wrong_type_is_refused( + m_keys, m_plugin, m_rate, m_limiter, m_stage, m_save, m_dispatch +): + """An X2TEXT id in the `llm` slot resolves fine and then fails deep inside + the engine as a provider error -- which reads like a broken model rather + than two swapped UUIDs. Caught at submit instead. + """ + key = kv_key() + m_keys.get.return_value = key + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + + with _adapters_owned_by(key.organization_id, types={"llm": "X2TEXT"}): + resp = ev.SubmitView.as_view()( + _real_multipart_post( + [ + { + "name": "table", + "keys": {"target_table": "Rent rolls"}, + "adapters": _TABLE_ADAPTERS, + "options": {}, + } + ], + ) + ) + + assert resp.status_code == 400, resp.data + assert "expected 'LLM'" in str(resp.data) + assert not m_dispatch.called + + +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save", autospec=True) +@mock.patch.object(ev, "stage_input") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) +@mock.patch.object(AgentKVKey, "objects") +def test_the_refusal_does_not_reveal_whether_the_adapter_exists( + m_keys, m_plugin, m_rate, m_limiter, m_stage, m_save, m_dispatch +): + """"Not yours" and "no such adapter" must read identically. + + Telling them apart turns the endpoint into an oracle for which adapter + UUIDs are real in other organizations. + """ + key = kv_key() + m_keys.get.return_value = key + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + + bodies = [] + # First: the id exists but belongs to someone else. Second: it exists + # nowhere. The stand-in cannot distinguish them either, which is the point. + for missing in ([_TABLE_ADAPTERS["llm"]], [_TABLE_ADAPTERS["llm"]]): + with _adapters_owned_by(key.organization_id, missing=missing): + resp = ev.SubmitView.as_view()( + _real_multipart_post( + [ + { + "name": "table", + "keys": {"target_table": "Rent rolls"}, + "adapters": _TABLE_ADAPTERS, + "options": {}, + } + ], + ) + ) + bodies.append(str(resp.data)) + + assert bodies[0] == bodies[1] diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 8cb78e06f6..a9592466fa 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -204,8 +204,54 @@ The request is **extractor-scoped**: a per-extractor schema and its knobs live i |---|---|---| | `name` | yes | Which extractor. **`table` is the only supported value on this deployment**; anything else, `kv` included, ⇒ 400 (see the note below). More than one entry ⇒ 400 — the format is in place for multi-extractor jobs, the execution is not built yet. | | `keys` | yes | This extractor's own request. For `table`, `{"target_table": ""}` — the engine's one required extraction parameter. | +| `adapters` | yes (for `table`) | The platform adapter instances this extraction runs on: `{"llm": "", "lite_llm": "", "x2text": ""}`. All three required. See **§3a** below. | | `options` | no | This extractor's own knobs (below). An unrecognised option ⇒ 400, so a knob aimed at the wrong extractor fails loudly instead of being silently dropped. | +### 3a. `adapters` — you supply the models and the OCR credentials + +The `table` extractor runs on **your own platform adapters**, named by id. Create +them once in Unstract (Settings → Adapters, or the adapter API) and pass their ids +on every submit: + +| Role | Adapter type | What it does | +|---|---|---| +| `llm` | `LLM` | Structure detection and extraction — the quality-critical model. | +| `lite_llm` | `LLM` | Per-page table-presence detection. Runs once per page, so this is the cost-critical one; a cheap fast model belongs here. | +| `x2text` | `X2TEXT` | OCR. An LLMWhisperer adapter, which also carries the endpoint — there is no separate base-URL field. | + +Each id is validated at submit, against **your organization** and against the +expected adapter type. A ⇒ 400, before anything is staged or billed: + +- an id that is not your organization's — reported identically to one that does not + exist, deliberately, so the endpoint cannot be used to discover which adapter ids + are real elsewhere +- an id of the wrong type, e.g. an `X2TEXT` adapter in the `llm` slot +- a missing role, or an unknown one + +Page usage and token usage are metered against the adapters you named, so **model +choice and LLM spend sit on your account**, per job. + +> **This is a deliberate reversal of the original design (spec D6).** D6 specified +> that this API would have "no platform adapters by design", with models and OCR +> credentials configured by the operator in environment variables and "no end-user +> model control". That was the right call while the API's consumer was hypothetical; +> it is the wrong one now that the consumers are existing Unstract customers. They +> already own adapters, there was no operator-level LLM credential anywhere in the +> platform to reuse — LLM credentials live in per-organization adapter rows, +> encrypted — and env configuration would have put every customer's model choice and +> every customer's LLM spend on a single shared operator key. +> +> Consequences worth knowing: the API is no longer usable by a caller who has never +> signed in, since adapters must exist first; and the six `AGENT_KV_LLM_*` / +> `AGENT_KV_LLMWHISPERER_*` environment variables are **not read on the table path** +> (they remain in place for the `kv` extractor, which is still env-configured). +> +> One thing this does not honour: adapters carry per-user visibility within an +> organization, and an Agent-KV key resolves to an organization rather than a user. +> So a key may use **any** adapter in its own organization. A key is already an +> organization-level credential, so this is a deliberate decision rather than an +> oversight — but treat key issuance accordingly. + > **Why `kv` returns 400 here.** This deployment ships the table extractor only. > The `kv` extractor's engine runs on its own release track and nothing on this > deployment consumes its queue, so the API refuses a `kv` submit outright @@ -290,7 +336,37 @@ that middleware to resolve an organization from. Cloud-only: an OSS-only deployment returns `501` before ever reaching it. `400`: serializer/schema validation failure — nothing billed. -### Example +### Example — `table` (the only extractor this deployment accepts) + +```bash +curl -X POST https://api.unstract.example/agent-kv/ \ + -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" \ + -F "file=@invoice.pdf" \ + -F 'extractors=[{ + "name": "table", + "keys": {"target_table": "Line items"}, + "adapters": { + "llm": "7f3c1a90-0000-4000-8000-000000000001", + "lite_llm": "7f3c1a90-0000-4000-8000-000000000002", + "x2text": "7f3c1a90-0000-4000-8000-000000000003" + }, + "options": { + "instructions": "Ignore the summary block at the foot of the page", + "enable_header_mapping": true + } + }]' \ + -F "page_start=1" -F "page_end=5" \ + -F "webhook_url=https://example.com/hooks/agent-kv" +``` + +The three adapter ids are yours, from your own organization — see §3a. Omit any of +them and the submit is a 400. + +### Example — `kv` + +Shown for the wire format only; **`kv` returns 400 on this deployment** (see the +note above). Note it takes no `adapters` block: the `kv` extractor is +env-configured on the operator side. ```bash curl -X POST https://api.unstract.example/agent-kv/ \ @@ -787,7 +863,21 @@ beyond `docker compose up`: ttl-cleanup additionally needs `storage` for `AGENT_KV_FILE_STORAGE_CREDENTIALS` (see item 6 below) — both already on the `backend` deployment's config list. 5. **Env vars**: every `AGENT_KV_*` setting plus `AGENT_KV_FILE_STORAGE_CREDENTIALS` — - see [§6](#12-environment-reference) and `docker/sample.env`. The executor-side vars + see [§6](#12-environment-reference) and `docker/sample.env`. + + > **For the `table` extractor, the executor-side credential group below is NOT + > required.** That path resolves the caller's own platform adapters (see §3a), so + > none of the six `AGENT_KV_LLM_*` / `AGENT_KV_LLMWHISPERER_*` values is read on + > it. The group only has to be configured where the `kv` extractor is deployed, + > which is a separate release track. A table-only deployment can leave + > `global.sharedConfigs.agentKv.enabled: false` and the API works. + > + > Two operator knobs are still read on the table path, independently of that + > group: `AGENT_KV_STORAGE_DIR_PREFIX` (which must match the backend's — the + > executor derives the job's expected directory from it to enforce the tenant + > boundary on the staged input) and `AGENT_KV_PARALLEL_PAGES`. + + The executor-side vars (read by the cloud `agentic_kv` plugin, not by the backend settings in §13) are a separate chart group: `global.sharedConfigs.agentKv` (`charts/unstract-platform/values.yaml`, cloud repo) — From 7ee61de54ea2e5ed067a6efc7f3529d575d54513 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Thu, 8 Oct 2026 11:26:24 +0530 Subject: [PATCH 17/54] =?UTF-8?q?UN-4232=20[FIX]=20Two=20P1=20review=20fin?= =?UTF-8?q?dings=20on=20the=20adapter=20change=20=E2=80=94=20one=20of=20th?= =?UTF-8?q?em=20broke=20every=20submit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **P1: the adapter lookup hid every valid adapter.** `_resolved_adapters` used `AdapterInstance.objects`, which is an `AdapterInstanceModelManager` and inherits `DefaultOrganizationManagerMixin.get_queryset()` -- that filters EVERY query by `UserContext.get_organization()`, a request-local thread-local the tenant middleware sets. `/agent-kv/` is deliberately whitelisted past that middleware (the org comes from the Bearer key, not the URL), so the thread-local is unset and the ambient filter became `organization=None`. Result: the gate refused EVERY submit, including a correctly-configured caller naming their own adapter, with "no such adapter in this organization". The API would not have worked at all. I wrote it by analogy to the `organization_id` filter in `_get_job`, and the analogy is false: `AgentKVJob` uses `BaseModelManager`, which does not auto-filter, so its explicit filter is the only one. `AdapterInstance` sets a different default manager. Verified empirically rather than assumed -- `type(AdapterInstance.objects)` is the filtering manager and `type(AdapterInstance._base_manager)` is a plain `Manager`. Fixed with `_base_manager`, Django's unfiltered manager, which exists for exactly this: internal lookups that must not inherit a custom default manager's filtering. That makes the explicit `organization_id` the ONLY org filter, which is why it stays required rather than optional. NOT fixed by setting the thread-local instead: that would silently re-scope every other org-managed query in the request, and `UserContext` keys on the org SLUG while the key carries the FK pk -- the confusion `dispatch._platform_api_key` documents shipping once already. Extracted as `_lookup_adapter`, because `_base_manager` is a read-only property that cannot be patched, and because mocking `.objects` is what hid this: every view test replaced the manager wholesale, so the manager's own behaviour never ran. **The test that was missing.** `tests/test_adapter_scoping.py` is DB-backed (`TestCase`, so the integration tier picks it up by inference) and exercises the real manager with the thread-local unset -- the only arrangement that reproduces the production path. Four cases: the org's own adapter resolves with no request context (the regression), another org's does not (bypassing the AMBIENT filter must not bypass the explicit one, or the fix would have opened the hole it exists to close), an unknown id is None, and `.objects` really does hide it -- so the explanation on `_lookup_adapter` stays truthful if that manager ever changes. Run against a real Postgres: 4 passed. Mutation-checked by reverting `_base_manager` to `.objects`, which fails the regression case. The unit suite could not have caught this at all. **P1: the e2e lane's submits carried no adapters**, so its shared helper got 400 where it asserted 202 and the lane could no longer validate table extraction on a deployed stack. `submit_raw`/`submit` now take `adapters`, defaulting to ids read from `AGENT_KV_E2E_LLM_ADAPTER` / `_LITE_LLM_ADAPTER` / `_X2TEXT_ADAPTER`. These have to come from the environment: they are rows in the deployed stack's database carrying real provider credentials, and an e2e client cannot mint them. Nine scenarios whose submit must be ACCEPTED now take a `require_adapters` fixture, which skips naming the unset variables -- following the lane's existing gate convention (`AGENT_KV_E2E`, `require_llm`, `AGENT_KV_E2E_BAD_KEY_JOB`) rather than inventing one. The seven scenarios that expect a rejection BEFORE the adapter gate (bad key 403, rate limit 429, absent `extractors`, bad schema 400) deliberately do not take it, and keep working unchanged. Also corrected the module docstring, which still described `AGENT_KV_LLM_API_KEY` as the gate for real extraction -- on the table path that credential now lives on the adapter, so a real-adapter run satisfies it whether or not the variable is exported. 259 unit + 4 integration backend tests. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 48 +++++++- .../agent_kv/tests/test_adapter_scoping.py | 112 ++++++++++++++++++ backend/agent_kv/tests/test_submit_view.py | 45 +++---- tests/e2e/agent_kv/conftest.py | 59 ++++++++- tests/e2e/agent_kv/test_agent_kv_e2e.py | 79 +++++++++--- 5 files changed, 297 insertions(+), 46 deletions(-) create mode 100644 backend/agent_kv/tests/test_adapter_scoping.py diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index 52479e3b20..4c610d5a93 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -288,6 +288,48 @@ def _discard_orphaned_input(job) -> None: ) +def _lookup_adapter(adapter_id, organization_id): + """One adapter belonging to `organization_id`, or ``None``. + + **`_base_manager`, NOT `.objects`.** `AdapterInstance.objects` is an + `AdapterInstanceModelManager`, which inherits + `DefaultOrganizationManagerMixin.get_queryset()` -- and that filters every + query by `UserContext.get_organization()`, a REQUEST-LOCAL thread-local the + tenant middleware sets. + + This route is deliberately whitelisted past that middleware (the org comes + from the Bearer key, not the URL), so the thread-local is unset here and the + ambient filter becomes `organization=None`, matching nothing. Through + `.objects` this gate refused EVERY submit -- including valid ones -- with + "no such adapter in this organization". + + That is NOT the same situation as `_get_job`, and reasoning by analogy to it + is how the defect got written: `AgentKVJob` uses `BaseModelManager`, which + does not auto-filter, so its explicit `organization_id=` is the only filter. + `AdapterInstance` sets a different default manager. + + `_base_manager` is Django's unfiltered manager and exists for exactly this + -- internal lookups that must not inherit a custom default manager's + filtering. It therefore makes the explicit `organization_id` below the ONLY + org filter, which is why that argument is required rather than optional. + + Not fixed by setting the thread-local instead: that would silently re-scope + every other org-managed query in the request, and `UserContext` keys on the + org SLUG while the key carries the FK pk -- the exact confusion + `dispatch._platform_api_key` documents shipping once already. + + A separate function so the view tests have an honest seam to patch: + `_base_manager` is a read-only property and cannot be patched, and mocking + `.objects` is what hid this defect in the first place. The real manager is + exercised by `tests/test_adapter_scoping.py`. + """ + from adapter_processor_v2.models import AdapterInstance + + return AdapterInstance._base_manager.filter( + id=adapter_id, organization_id=organization_id + ).first() + + def _resolved_adapters(entry: dict, agent_kv_key) -> dict: """Confirm every adapter the caller named is THIS org's, and of the right type. @@ -329,14 +371,10 @@ def _resolved_adapters(entry: dict, agent_kv_key) -> dict: if not requested: return {} - from adapter_processor_v2.models import AdapterInstance - _, expected_types = _ADAPTERS_SERIALIZERS[entry["name"]] for role, adapter_id in requested.items(): expected = expected_types[role] - adapter = AdapterInstance.objects.filter( - id=adapter_id, organization_id=agent_kv_key.organization_id - ).first() + adapter = _lookup_adapter(adapter_id, agent_kv_key.organization_id) if adapter is None: # One message for "no such adapter" and "not yours", on purpose: # telling them apart reveals which UUIDs exist. diff --git a/backend/agent_kv/tests/test_adapter_scoping.py b/backend/agent_kv/tests/test_adapter_scoping.py new file mode 100644 index 0000000000..704fd849e6 --- /dev/null +++ b/backend/agent_kv/tests/test_adapter_scoping.py @@ -0,0 +1,112 @@ +"""The adapter lookup must work with NO request-local organization context. + +This is the test whose absence let a P1 through. `_resolved_adapters` was +written with `AdapterInstance.objects`, by analogy to the `organization_id` +filter in `_get_job` -- but the two models have different default managers: + +* `AgentKVJob` uses `BaseModelManager`, which does not auto-filter, so its + explicit `organization_id=` is the only filter. +* `AdapterInstance` sets `AdapterInstanceModelManager`, which inherits + `DefaultOrganizationManagerMixin.get_queryset()` and filters EVERY query by + `UserContext.get_organization()` -- a request-local thread-local set by the + tenant middleware. + +`/agent-kv/` is deliberately whitelisted past that middleware (the org comes +from the Bearer key, not the URL), so the thread-local is unset and the ambient +filter became `organization=None`. Every valid submit was refused with "no such +adapter in this organization". + +Every test in `test_submit_view.py` mocked the lookup, so none of them could +see it. These run against the real manager with the real thread-local unset -- +which is the only arrangement that reproduces the production path. + +DB-backed, so `TestCase` and the integration tier (`backend/conftest.py` +auto-marks on that basis; no manual marker). +""" + +import os +import uuid + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from django.test import TestCase # noqa: E402 + +from account_v2.models import Organization # noqa: E402 +from adapter_processor_v2.models import AdapterInstance # noqa: E402 +from agent_kv.execution_views import _lookup_adapter # noqa: E402 +from utils.user_context import UserContext # noqa: E402 + + +class TestAdapterLookupWithoutRequestContext(TestCase): + def setUp(self): + self.org = Organization.objects.create( + display_name="Acme", organization_id="org_acme", name="acme" + ) + self.other = Organization.objects.create( + display_name="Other", organization_id="org_other", name="other" + ) + self.adapter = AdapterInstance.objects.create( + adapter_name="acme-llm", + adapter_id="anthropic|abc", + adapter_type="LLM", + adapter_metadata={}, + organization=self.org, + ) + # The state the whitelisted route actually runs in: no tenant + # middleware has run, so there is no ambient organization. + UserContext.set_organization_identifier(None) + + def test_the_lookup_finds_the_org_s_own_adapter_with_no_user_context(self): + """The regression. Through `.objects` this returned None and the submit + was a 400 -- for a correctly-configured caller naming their own adapter. + """ + self.assertIsNone( + UserContext.get_organization(), + "precondition: this route has no ambient organization", + ) + + found = _lookup_adapter(self.adapter.id, self.org.id) + + self.assertIsNotNone( + found, + "the caller's own adapter must resolve without a request-local " + "organization; `.objects` filters by one and returns nothing here", + ) + self.assertEqual(found.id, self.adapter.id) + self.assertEqual(found.adapter_type, "LLM") + + def test_the_org_filter_still_excludes_another_org_s_adapter(self): + """Bypassing the AMBIENT filter must not bypass the explicit one. + + `_base_manager` is unfiltered, so the `organization_id` argument is the + only tenant boundary left -- if it stopped working, the fix for the + regression above would have opened the cross-tenant hole it exists to + close. + """ + self.assertIsNone(_lookup_adapter(self.adapter.id, self.other.id)) + + def test_an_unknown_id_resolves_to_none(self): + self.assertIsNone(_lookup_adapter(uuid.uuid4(), self.org.id)) + + def test_the_ambient_manager_really_does_hide_it(self): + """Pins the cause, so the comment on `_lookup_adapter` stays truthful. + + If `AdapterInstance.objects` ever stops auto-filtering by the + thread-local, this fails and `_base_manager` is no longer necessary -- + at which point the explanation should be revisited rather than left + asserting something that is no longer true. + """ + via_objects = AdapterInstance.objects.filter( + id=self.adapter.id, organization_id=self.org.id + ).first() + + self.assertIsNone( + via_objects, + "`.objects` is expected to hide the adapter here -- that is the " + "whole reason `_lookup_adapter` uses `_base_manager`", + ) diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py index 429ac44de6..00e6a21b8e 100644 --- a/backend/agent_kv/tests/test_submit_view.py +++ b/backend/agent_kv/tests/test_submit_view.py @@ -119,33 +119,36 @@ def _mock_serializer(m_cls, **overrides): def _adapters_owned_by(organization_id, *, types=None, missing=()): - """Patch the adapter lookup `_resolved_adapters` performs. - - The gate runs `AdapterInstance.objects.filter(id=..., organization_id=...) - .first()`, so this stands in for the ORM: an id in `missing` resolves to - None (not this org's, or nonexistent -- the gate cannot and must not tell - those apart), and everything else resolves to an adapter of the type - `types` gives, defaulting to the correct one for its role. + """Patch the single-adapter lookup `_resolved_adapters` performs. + + Patches `ev._lookup_adapter`, the view's own seam, rather than the ORM. + Two reasons, both learned the hard way: `AdapterInstance._base_manager` is + a read-only property and cannot be patched at all, and patching `.objects` + is what hid the defect where the gate refused every valid submit (that + manager auto-filters by a request-local org this route never sets). + + So these tests assert the GATE's logic -- org match, type match, identical + refusals -- and `tests/test_adapter_scoping.py` asserts that the real + lookup finds a real adapter with no request context. Neither covers the + other. + + An id in `missing` resolves to None (not this org's, or nonexistent -- the + gate cannot and must not tell those apart); everything else resolves to an + adapter of the type `types` gives, defaulting to the right one per role. """ - from adapter_processor_v2.models import AdapterInstance - type_by_id = { aid: (types or {}).get(role, _ROLE_TYPES[role]) for role, aid in _TABLE_ADAPTERS.items() } + missing_ids = {str(m) for m in missing} + + def _lookup(adapter_id, org_id): + aid = str(adapter_id) + if aid in missing_ids or org_id != organization_id: + return None + return mock.Mock(adapter_type=type_by_id.get(aid, "LLM")) - def _filter(**kw): - qs = mock.Mock() - aid = str(kw.get("id")) - if aid in {str(m) for m in missing} or kw.get( - "organization_id" - ) != organization_id: - qs.first.return_value = None - else: - qs.first.return_value = mock.Mock(adapter_type=type_by_id.get(aid, "LLM")) - return qs - - return mock.patch.object(AdapterInstance, "objects", **{"filter.side_effect": _filter}) + return mock.patch.object(ev, "_lookup_adapter", side_effect=_lookup) def _stamp_created_at(job, *args, **kwargs): diff --git a/tests/e2e/agent_kv/conftest.py b/tests/e2e/agent_kv/conftest.py index 9b16da9a90..673787e07f 100644 --- a/tests/e2e/agent_kv/conftest.py +++ b/tests/e2e/agent_kv/conftest.py @@ -13,6 +13,7 @@ from __future__ import annotations import json +import os import time import uuid from dataclasses import dataclass @@ -149,6 +150,38 @@ def kv_stages(status_doc: dict) -> list[dict]: return status_doc["extractors"]["kv"]["stages"] +#: The platform adapters an Agent-KV `table` submit must name, read from the +#: environment because they are rows in the deployed stack's database -- an +#: e2e client cannot mint them, and they carry real provider credentials. +#: +#: Unset by default, which makes every submit a 400. The `require_adapters` +#: fixture is what turns that into an explicit skip instead of a confusing +#: failure; scenarios that expect a rejection BEFORE the adapter gate (bad +#: key, rate limit, absent `extractors`) do not need it. +#: +#: Create them once in the target org -- an LLM adapter, a second cheaper LLM +#: adapter, and an LLMWhisperer X2TEXT adapter -- and export their ids. +E2E_ADAPTER_ENV = { + "llm": "AGENT_KV_E2E_LLM_ADAPTER", + "lite_llm": "AGENT_KV_E2E_LITE_LLM_ADAPTER", + "x2text": "AGENT_KV_E2E_X2TEXT_ADAPTER", +} + + +def e2e_adapters() -> dict[str, str]: + """The configured adapter ids, by role; roles with no env var are omitted.""" + return { + role: os.environ[var] + for role, var in E2E_ADAPTER_ENV.items() + if os.environ.get(var) + } + + +def missing_adapter_env() -> list[str]: + """Which adapter env vars are unset, for a skip message that names them.""" + return [var for var in E2E_ADAPTER_ENV.values() if not os.environ.get(var)] + + def submit_raw( auth: AgentKVAuth, file_bytes: bytes, @@ -156,6 +189,7 @@ def submit_raw( keys: dict | None, *, extractor: str = "table", + adapters: dict[str, str] | None = None, **fields: object, ) -> requests.Response: """POST a submit request and return the raw response -- no assertions. @@ -184,9 +218,19 @@ def submit_raw( f"belongs there" ) else: - data["extractors"] = json.dumps( - [{"name": extractor, "keys": keys, "options": options}] - ) + entry: dict[str, object] = { + "name": extractor, + "keys": keys, + "options": options, + } + # `adapters` is REQUIRED for `table` -- the caller names the LLM and + # OCR adapters the extraction runs on. Defaults to whatever the + # environment configured; `{}` is sent as-is so a scenario can assert + # the 400 for an absent block. + resolved = e2e_adapters() if adapters is None else adapters + if resolved: + entry["adapters"] = resolved + data["extractors"] = json.dumps([entry]) return requests.post( auth.exec_url, headers=auth.headers, @@ -203,6 +247,7 @@ def submit( keys: dict, *, extractor: str = "table", + adapters: dict[str, str] | None = None, **fields: object, ) -> tuple[str, str]: """POST a submit request expected to succeed; return (job_id, status_url). @@ -210,9 +255,13 @@ def submit( Asserts the 202 handshake (spec §7.1 / docs §3) so every caller only ever polls a genuinely dispatched job. A 501 here almost always means the cloud ``agentic_table`` executor plugin isn't installed on this deployment - -- this whole lane requires it (docs §11). + -- this whole lane requires it (docs §11). A 400 naming `adapters` + means the `AGENT_KV_E2E_*_ADAPTER` ids are unset or do not belong to this + key's organization -- gate such a scenario on `require_adapters`. """ - resp = submit_raw(auth, file_bytes, filename, keys, extractor=extractor, **fields) + resp = submit_raw( + auth, file_bytes, filename, keys, extractor=extractor, adapters=adapters, **fields + ) assert resp.status_code == 202, ( f"submit: HTTP {resp.status_code} (expected 202; a 501 means the " f"agentic_table executor plugin isn't installed on this deployment): {resp.text}" diff --git a/tests/e2e/agent_kv/test_agent_kv_e2e.py b/tests/e2e/agent_kv/test_agent_kv_e2e.py index 6bff877d2e..c515ca5dd0 100644 --- a/tests/e2e/agent_kv/test_agent_kv_e2e.py +++ b/tests/e2e/agent_kv/test_agent_kv_e2e.py @@ -10,13 +10,22 @@ accident just because a platform happens to be up; it must be opted into for a run that actually ships the plugin. -A second, narrower gate (the ``require_llm`` fixture below) applies only to -the two scenarios whose assertions depend on a job actually reaching -COMPLETED (real extraction happened): either ``AGENT_KV_LLM_API_KEY`` or -``UNSTRACT_LLM_MOCK_RESPONSE`` must be set. Every other scenario here is -gated purely by request-time checks (auth, schema compile, the concurrency -limiter, the pre-OCR page cap) that resolve before any LLM call, so they -don't need either. +A second gate (the ``require_adapters`` fixture) applies to every scenario +whose submit must be ACCEPTED. `table` submits now name the caller's own +``llm`` / ``lite_llm`` / ``x2text`` platform adapters, and the API validates +each id against the Bearer key's organization and its adapter type -- so +without ``AGENT_KV_E2E_LLM_ADAPTER`` / ``_LITE_LLM_ADAPTER`` / +``_X2TEXT_ADAPTER`` every submit is a 400 before dispatch. Scenarios that +expect a rejection BEFORE that gate (a bad key, the rate limiter, an absent +``extractors``) do not take it. + +A third, narrower gate (``require_llm``) applies only to the scenarios whose +assertions depend on a job actually reaching COMPLETED: either +``AGENT_KV_LLM_API_KEY`` or ``UNSTRACT_LLM_MOCK_RESPONSE`` must be set. Note +that on the table path the LLM credential now lives on the ADAPTER rather +than in the executor's environment, so a real-adapter run satisfies this +whether or not that variable is exported -- it is kept for the mock case and +for the KV path. Values extracted under ``UNSTRACT_LLM_MOCK_RESPONSE`` are whatever the mock config returns, not real answers -- assertions here deliberately check @@ -64,6 +73,7 @@ AgentKVAuth, cancel, delete, + missing_adapter_env, poll, result, submit, @@ -161,6 +171,36 @@ def _drain_until_slot_free( return False +@pytest.fixture +def require_adapters() -> None: + """Skip a scenario that needs a submit to be ACCEPTED (202). + + `table` submits must name the caller's own `llm` / `lite_llm` / `x2text` + platform adapters, and the API validates each id against the Bearer key's + organization and against its adapter type. Those are rows in the deployed + stack's database carrying real provider credentials -- an e2e client cannot + create them, so the ids come from the environment. + + Without them every submit is a 400 before dispatch, so this gate turns an + unconfigured lane into an explicit skip rather than a wall of confusing + failures. Scenarios that expect a rejection BEFORE the adapter gate -- a + bad key (403), the rate limiter (429), an absent `extractors` field -- + deliberately do not take this fixture. + + This replaces what `AGENT_KV_LLM_API_KEY` used to provide for the table + path: the credentials now live on the adapters, not in the executor's + environment. + """ + missing = missing_adapter_env() + if missing: + pytest.skip( + "needs the caller's platform adapters so a submit can be accepted; " + f"unset: {', '.join(missing)}. Create an LLM adapter, a cheaper " + "LLM adapter and an LLMWhisperer X2TEXT adapter in the target org " + "and export their ids." + ) + + @pytest.fixture def require_llm() -> None: """Skip a scenario that needs a job to actually reach COMPLETED. @@ -198,7 +238,9 @@ def test_submit_without_key_is_403(agent_kv_key: AgentKVAuth) -> None: # --------------------------------------------------------------------------- -def test_table_extractor_happy_path(agent_kv_key: AgentKVAuth, require_llm: None) -> None: +def test_table_extractor_happy_path( + require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None +) -> None: """A `table` entry runs the table engine and files its result under `table`. Before the job row recorded its extractor, every response keyed by the @@ -318,7 +360,7 @@ def test_submit_unreadable_pdf_is_400(agent_kv_key: AgentKVAuth) -> None: # --------------------------------------------------------------------------- -def test_cancel_mid_run(agent_kv_key: AgentKVAuth) -> None: +def test_cancel_mid_run(require_adapters: None, agent_kv_key: AgentKVAuth) -> None: job_id, _ = submit( agent_kv_key, _RENT_ROLL_BYTES, @@ -353,6 +395,7 @@ def test_cancel_mid_run(agent_kv_key: AgentKVAuth) -> None: def test_cancelled_job_does_not_leak_its_concurrency_slot( + require_adapters: None, agent_kv_key: AgentKVAuth, ) -> None: """A cancelled job must give its concurrency slot back. @@ -396,7 +439,7 @@ def test_cancelled_job_does_not_leak_its_concurrency_slot( def test_delete_completed_job_then_result_404( - agent_kv_key: AgentKVAuth, require_llm: None + require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None ) -> None: job_id, _ = submit( agent_kv_key, @@ -450,7 +493,9 @@ def _assert_user_safe_error(error: str) -> None: assert leak not in error, f"a filesystem path leaked into: {error!r}" -def test_bad_llm_key_ends_failed(agent_kv_key: AgentKVAuth) -> None: +def test_bad_llm_key_ends_failed( + require_adapters: None, agent_kv_key: AgentKVAuth +) -> None: """A job that DISPATCHES and then fails in the executor ends ``failed`` with a user-safe error. @@ -506,7 +551,7 @@ def test_bad_llm_key_ends_failed(agent_kv_key: AgentKVAuth) -> None: def test_sync_wait_submit_returns_result_inline( - agent_kv_key: AgentKVAuth, require_llm: None + require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None ) -> None: """A submit with ``timeout`` seconds sync-waits: if the job reaches a terminal state inside the window, the response is 200 with the result @@ -539,7 +584,9 @@ def test_sync_wait_submit_returns_result_inline( # --------------------------------------------------------------------------- -def test_excel_submit_extracts(agent_kv_key: AgentKVAuth, require_llm: None) -> None: +def test_excel_submit_extracts( + require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None +) -> None: """An .xlsx submit runs the whole pipeline. Excel has no pre-OCR page count (``pages_total`` stays None at submit) and takes the post-OCR virtual-page cap path instead. Structure-only assertions, like the @@ -562,7 +609,7 @@ def test_excel_submit_extracts(agent_kv_key: AgentKVAuth, require_llm: None) -> def test_webhook_delivered_on_completion( - agent_kv_key: AgentKVAuth, require_llm: None + require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None ) -> None: """Completion-webhook delivery, end to end: submit with a ``webhook_url`` pointing at a receiver on the compose host and wait for the POST. @@ -623,7 +670,9 @@ def log_message(self, *args): # keep pytest output pristine thread.join(timeout=5) -def test_concurrency_limit_returns_429(agent_kv_key: AgentKVAuth) -> None: +def test_concurrency_limit_returns_429( + require_adapters: None, agent_kv_key: AgentKVAuth +) -> None: """Saturate the org's concurrency limiter; every other submit-based scenario in this module runs before this one, and ``tests/groups.yaml`` pins this whole group ``parallel: false`` -- both for the same reason: From 82d51813bf3be471df47e286efe346d4de751b1a Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Thu, 8 Oct 2026 15:05:03 +0530 Subject: [PATCH 18/54] UN-4232 [FIX] Round-2 review: a billing control the gate skipped, two dead guards, and one false premise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1 (CONFIRMED, the one with teeth). The adapter gate checked organization and adapter_type and nothing else. `is_usable` is how billing cuts off a frictionlessly onboarded org's operator-funded trial, and the platform service does NOT check it -- it hands the credentials back regardless. So the IDE, workflows and Prompt Studio all refuse an exhausted trial (tool_instance_helper.py:533, prompt_studio_helper.py:149/203/3292) while an Agent-KV submit naming the SAME adapter id got a 202 and ran on credentials the operator pays for and has already cut off. Added `is_usable` and `is_available` (deprecated -> `InValidAdapterId` deep in the engine, now a 400 at submit), reusing the platform's own message. A3. Migration 0004 seeded two PgPeriodicTask rows on the stated premise that there was "no PgPeriodicTask, no beat entry, no CronJob". That premise was false about its own tree: the cloud chart already ran the same two management commands, added four commits earlier on this branch. The rows were `pg_owned: False` so nothing double-fired yet -- but that flag exists to be flipped, and a flip would have put a */10 sweep against the chart's */15, colliding at :30 on the same rows, with TTL cadences disagreeing 24x. Only the CronJob side has `concurrencyPolicy: Forbid` and a deadline. 0004 now DELETES the rows (idempotent, name-scoped, reverse restores them inert) and the CronJob is the sole owner. Verified forward/reverse/re-apply against a real DB, including that the five dashboard_metrics periodics are untouched. Self-hosted OSS operators are told to schedule the two commands themselves -- docs §12. C1. `_never_dispatched` read the in-memory row, fetched BEFORE `mark_terminal`. A dispatch completing in that gap made the cancel release a slot whose executor was running and billing -- a smaller instance of the over-release bug the narrowing was written to fix. Now re-reads `dispatched_at` from the DB. The residual enqueue-to-bookkeeping window is documented rather than papered over: bounded at one slot, self-correcting via the idempotent finalize release. C2. Sweep phase 3 counted slot releases, logged them, and returned only `{swept, timed_out}` -- so neither `SweepView` nor the scheduler log could ever show a slot was recovered. Now returns `released` too. E9. Nothing recorded which adapters a run spent. Adapter choice is now per-request, caller-controlled and cost-bearing, so "which model did job X use?" needed a usage_v2 join the API cannot do and the customer cannot see. New `AgentKVJob.adapters` (migration 0006), populated at submit and reported in the status document. Ids only, never adapter_metadata -- that column is returned to the caller. B1. The queue guard was literal-pinned, so re-enabling `kv` would have required DELETING its inverse assertion to go green. Added `tests/test_queue_wiring_is_derived.py`, which derives both directions from `EXTRACTOR_ROUTES` (and so flips on its own), covers `run-worker-docker.sh` (guarded by nothing -- dropping `agent_kv_callback` there passed every test while a docker-launched fleet drained no terminal callbacks), and asserts the derivation is non-vacuous. Also derives the billing backstop: every routed operation must be in `_LLM_BEARING_OPS`, which the two hand-maintained literals could not catch when both were wrong together. The workers-side suite keeps its literals as a second opinion, now cross-referenced. The dead-Celery-var test is NOT parametrized -- review asked for that and dev compose legitimately sets the variable; generalized to "never advertises an unserved queue" instead, over both files. B2. The format-typo guard missed the two formats that take an argument: `_parse_format` matches the `enum:`/`regex:` prefix case-sensitively, so `format: "Enum:paid,unpaid"` never became kind `enum`, slipped the guard as free text, and validation was silently off for that key -- the exact failure the round-1 fix closed for bare names. Now splits on the colon. Rejected rather than auto-corrected, matching the bare case. B7 / C3. Pinned two round-1 fixes that no test could detect: `maintenance.py`'s logging (nothing used caplog on it; all five lines were deletable) and the `except Exception: pass` in `ide_callback/agent_kv_tasks.py` (its test was byte-identical before and after). Fixed the same bare `pass` in the sibling `ide_callback/tasks.py`. Replaced `test_agent_kv_error_matches_its_siblings_failure_posture`, which asserted `agent_kv_complete is not None`, with one that actually drives both callbacks through a failing finalize. Corrected the `retry_backoff` docstring: the PG consumer runs tasks via `task.apply(throw=True)` -- eager -- so eager retry recurses synchronously and ignores countdown; the three attempts are back-to-back and ride out no blip. The real delay comes from vt-redelivery after the raise. C4. `require_llm` gated the only two scenarios that poll a job to a genuine COMPLETED on `AGENT_KV_LLM_API_KEY` -- a variable the table path does not read, since the credential is on the adapter. A correctly configured lane SKIPPED them. Removed; `require_adapters` is the whole gate. `test_bad_llm_key_ends_failed` had lost its trigger entirely (`AGENT_KV_E2E_BAD_KEY_JOB` is unread, so an operator following the docs got a job that reached `completed` and failed the test's own assertion) -- re-pointed at `AGENT_KV_E2E_BAD_LLM_ADAPTER`, where the credential now lives. Part D. `test_submit_unreadable_pdf_is_400` and `test_page_cap_rejects_oversized_document` went RED, not skipped, without adapter env: the adapter shape check runs in a FIELD validator, so DRF raises it before `validate()` and the blamed attr is `extractors`, not `file`. Both now take `require_adapters`. Replaced the identical-refusal test that ran the same configuration twice and compared the body to itself -- the oracle property is now asserted from two genuinely different DB states in `test_adapter_scoping.py`, which also gained the `adapter_type` comparison against a real row (previously only ever against a mock that chose its own value) and explicit `is_usable`/`is_available` on every stand-in, since a bare `Mock` satisfies any attribute the gate reads. E1. Four places across both repos asserted the submit gate was the only tenancy check and were cited as the justification for its design. It is not: platform-service resolves adapters with `WHERE id=%s and organization_id=%s` against the org of the key `dispatch._platform_api_key(job)` mints from the job's org, so org A cannot spend org B's credential even with this gate removed. Corrected to defence-in-depth, with what the gate actually buys: a clean 400, plus the two refusals the platform service does not make. E7. Documented that the page cap bounds `pages_selected` (after page_start/page_end), not the document's length -- an unlisted behaviour change. A4. Deploy-order skew: a new backend against an OLD worker sends validated `adapters` to a worker with no `ParamKeys.ADAPTERS`, which ignores them and calls `from_env()`. Where the six AGENT_KV_* values are set it runs on OPERATOR credentials and returns a correct-looking 202. Documented: deploy cloud first, and leave that group unset. Also: the stale "see the backend migration that schedules them" in workers/scheduler, and `_resolved_adapters`' discarded return value (now assigned back, so future normalization reaches dispatch). New: `unstract/sdk1/tests/test_page_usage_metering.py` pins that non-PDF inputs meter as ONE page while PDFs meter per real page. That under-counts Excel, which the table engine splits into virtual pages -- product-wide, rooted in the SDK's own TODO, and affecting the IDE path identically. Pinned rather than changed: correcting it re-prices every spreadsheet extraction and needs sign-off. Verification: agent_kv 294 passed; workers (ide_callback, config, webhook, client, scheduler, queue-wiring, llm-bearing-ops) 74 passed 1 skipped; agent-kv-schema 138 passed; sdk1 766 passed. Migrations 0004 forward/reverse and 0006 applied against a real Postgres; `makemigrations --check` clean. ruff 0.3.4 check + format clean. Every fix above was mutation-verified by reverting it and confirming the new test fails. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/dispatch.py | 19 +- backend/agent_kv/execution_views.py | 132 ++++++-- backend/agent_kv/maintenance.py | 15 +- .../migrations/0004_pg_periodic_tasks.py | 98 +++--- .../migrations/0006_agentkvjob_adapters.py | 30 ++ backend/agent_kv/models.py | 19 ++ .../agent_kv/tests/test_adapter_scoping.py | 213 ++++++++++++- .../tests/test_cross_repo_stage_name.py | 52 +++ backend/agent_kv/tests/test_job_views.py | 73 ++++- .../tests/test_periodic_tasks_migration.py | 165 +++++++--- .../tests/test_queue_wiring_is_derived.py | 297 ++++++++++++++++++ backend/agent_kv/tests/test_submit_view.py | 129 ++++++-- backend/agent_kv/tests/test_sweeps.py | 107 ++++++- docs/agent-kv-api.md | 78 ++++- tests/e2e/agent_kv/conftest.py | 9 + tests/e2e/agent_kv/test_agent_kv_e2e.py | 127 +++++--- .../src/unstract/agent_kv_schema/compile.py | 32 +- .../agent-kv-schema/tests/test_compile.py | 66 ++++ .../sdk1/tests/test_page_usage_metering.py | 172 ++++++++++ workers/ide_callback/agent_kv_tasks.py | 19 +- workers/ide_callback/tasks.py | 19 +- .../tests/test_agent_kv_callbacks.py | 91 +++++- workers/scheduler/agent_kv_tasks.py | 10 +- workers/tests/test_llm_bearing_ops.py | 18 +- workers/tests/test_queue_consumer_wiring.py | 60 ++++ 25 files changed, 1826 insertions(+), 224 deletions(-) create mode 100644 backend/agent_kv/migrations/0006_agentkvjob_adapters.py create mode 100644 backend/agent_kv/tests/test_queue_wiring_is_derived.py create mode 100644 unstract/sdk1/tests/test_page_usage_metering.py diff --git a/backend/agent_kv/dispatch.py b/backend/agent_kv/dispatch.py index f45dcb3827..7f6cb4276f 100644 --- a/backend/agent_kv/dispatch.py +++ b/backend/agent_kv/dispatch.py @@ -120,10 +120,21 @@ def dispatch_job( # Platform adapter instance ids, by role, already validated at # submit against THIS job's organization and against the # expected `AdapterTypes` (see - # `execution_serializers.validated_adapters`). The executor - # resolves them through the platform service using the key - # below -- it does NOT re-check tenancy, so the submit-time - # check is the only one there is. + # `execution_serializers._validated_adapter_shape` for the + # shape half and `execution_views._resolved_adapters` for the + # tenancy half). + # + # Defence in depth, NOT the only check: the platform service + # re-scopes every lookup as + # `WHERE id=%s and organization_id=%s` + # (platform-service/.../helper/adapter_instance.py:28-30), + # where the org comes from the bearer platform key that + # `_platform_api_key(job)` below mints from THIS job's org. So + # org A cannot spend org B's credential even with the submit + # gate removed. What the submit gate buys is a clean 400 naming + # the role instead of a mid-run `SdkError`, plus the two checks + # the platform service does NOT make: `is_usable` (exhausted + # trial) and `is_available` (deprecated). # # Empty for an env-configured extractor (`kv`), which is why # this is a dict rather than three params: the two credential diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index 4c610d5a93..93d1699167 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -80,6 +80,12 @@ def _status_document(job) -> dict: ], } }, + # Which adapters this run spent, by role. Reported because the caller + # chose them and is paying for them: a run naming the wrong `llm` id + # produces a correct-looking result at a different price, and without + # this the only record is in `usage_v2`, which the caller cannot read. + # `{}` for an env-configured extractor. + "adapters": job.adapters or {}, "created_at": job.created_at.isoformat() if job.created_at else None, "started_at": job.dispatched_at.isoformat() if job.dispatched_at else None, "completed_at": job.completed_at.isoformat() if job.completed_at else None, @@ -93,13 +99,37 @@ def _status_document(job) -> dict: def _never_dispatched(job) -> bool: """True when this job never reached an executor, so no callback will come. - Read from the row as it was BEFORE this request terminalized it. Both - conditions are deliberate: `dispatched_at` is stamped by post-enqueue - bookkeeping that can legitimately fail, and `status` is what the dispatcher - advances -- a job with either marker set has, or may have, a running - executor whose finalize callback owns the slot. + **Re-read from the database, not from the in-memory row.** The row the + cancel path holds was fetched BEFORE `mark_terminal`, and a dispatch can + complete inside that gap: + + cancel reads the row -> PENDING, dispatched_at=NULL + dispatch_job -> passes its own terminal re-read, enqueues, + writes dispatched_at and DISPATCHED + cancel's mark_terminal-> wins (DISPATCHED is not terminal) + _never_dispatched -> True on the STALE row + + which releases the slot while the executor is running and billing. That is + a smaller version of the original over-release bug this narrowing was + written to fix, reintroduced by trusting the pre-cancel snapshot. + + Only `dispatched_at` is consulted, and `status` deliberately is not: by the + time this runs the cancel has already rewritten `status` to CANCELLED, so + the old `status == PENDING` test could only ever be answered from the stale + object. `dispatched_at` is the durable marker of "an executor may exist" + and the cancel never touches it. + + **Residual window, stated rather than papered over.** `dispatch_job` + enqueues and only then writes `dispatched_at` (`_record_dispatch`). A cancel + landing between those two still sees NULL and still releases early. The + consequence is bounded and self-correcting: one extra concurrent slot for + the remainder of that one run, after which the executor's finalize releases + again (`release` is an idempotent zrem). Closing it completely means + stamping a dispatch-intent marker before the enqueue, which changes what + `dispatched_at` means to both sweep phases -- a larger change than the + defect warrants. """ - return job.dispatched_at is None and job.status == JobStatus.PENDING + return not AgentKVJob.objects.filter(id=job.id, dispatched_at__isnull=False).exists() def _fail_job_response(job, org_id: str, message: str, *, job_saved: bool) -> Response: @@ -338,13 +368,23 @@ def _resolved_adapters(entry: dict, agent_kv_key) -> dict: (`_validated_adapter_shape`); what is added here is the half that needs a database and the Bearer key. - **This is the tenant boundary on adapters, and nothing downstream repeats - it.** The executor resolves these ids through the platform service with an - org-scoped platform key, but it does not re-check that the id belongs to - the job's org -- so without this, a caller passes any adapter UUID and the - run SPENDS ANOTHER TENANT'S LLM CREDENTIAL. Same class as the - `organization_id` filter in `_get_job`, and the highest-consequence failure - this endpoint has. + **Defence in depth, not the only tenancy check** -- an earlier version of + this docstring claimed it was, and was wrong. The platform service re-scopes + every adapter lookup as `WHERE id=%s and organization_id=%s` + (platform-service/.../helper/adapter_instance.py:28-30), against the org of + the bearer platform key that `dispatch._platform_api_key(job)` mints from + THIS job's org. Org A therefore cannot spend org B's credential even if this + gate were deleted outright. + + What this gate does buy, and why it is still worth having: + + - A clean 400 naming the offending role at submit, instead of a mid-run + `SdkError` on a job that already took a concurrency slot and billed for + staging. + - The two refusals the platform service does NOT make: `is_usable` (a + frictionless org's operator-funded trial is exhausted -- it hands back the + credentials anyway) and `is_available` (deprecated, removed from the SDK + registry). It lives in the view rather than the serializer for the same reason `_subscription_denial` does: it needs the key. The platform's own helpers @@ -390,6 +430,43 @@ def _resolved_adapters(entry: dict, agent_kv_key) -> dict: ) } ) + # Trial exhaustion. `is_usable` is flipped False when a frictionlessly + # onboarded org burns through its operator-funded sample allowance, and + # the platform service does NOT check it -- it returns the credentials + # regardless. So without this the IDE, workflows and Prompt Studio all + # refuse (tool_instance_helper.py, prompt_studio_helper.py) while an + # Agent-KV submit naming the SAME adapter id gets a 202 and runs on + # credentials the operator is paying for and has already cut off. + # + # Same message those surfaces use, so an exhausted trial reads + # identically whichever door the caller knocks on. + if not adapter.is_usable: + logger.error( + "agent-kv: free usage for sample adapter %s is exhausted", adapter.id + ) + raise ValidationError( + { + "adapters": ( + f"{role}: free usage for the configured trial adapter is " + "exhausted. Connect your own service account to continue: " + "https://docs.unstract.com/unstract_platform/setup_accounts/" + "whats_needed" + ) + } + ) + # Deprecated: removed from the SDK registry, so resolving it raises + # `InValidAdapterId` (adapter_processor.py:120) deep inside the engine + # and surfaces to the caller as a mid-run extraction failure on a job + # that was already billed for staging. A 400 at submit names the cause. + if not adapter.is_available: + raise ValidationError( + { + "adapters": ( + f"{role}: adapter is deprecated and no longer available; " + "pick a current one" + ) + } + ) return requested @@ -415,17 +492,30 @@ def post(self, request, *args, agent_kv_key=None, **kwargs): v = serializer.validated_data org_id = str(agent_kv_key.organization_id) - # Tenancy + type on the named adapters, HERE and not later: before the - # job row, before the concurrency slot, before the staging write. A - # caller who names another org's adapter, or swaps two UUIDs, gets a - # 400 and is charged nothing -- the same "every cap before paid work" - # rule the serializer's caps follow. - _resolved_adapters(v["extractors"][0], agent_kv_key) + # Tenancy + type + usability on the named adapters, HERE and not later: + # before the job row, before the concurrency slot, before the staging + # write. A caller who names another org's adapter, swaps two UUIDs, or + # points at an exhausted trial gets a 400 and is charged nothing -- the + # same "every cap before paid work" rule the serializer's caps follow. + # + # Assigned back rather than discarded: `_dispatch_or_fail` reads + # `entry["adapters"]` to build `executor_params`, so anything this gate + # ever normalizes (it returns the dict unchanged today) must land where + # dispatch will see it. Discarding the return made the two paths agree + # only by the accident of being the same dict object. + v["extractors"][0]["adapters"] = _resolved_adapters( + v["extractors"][0], agent_kv_key + ) job = AgentKVJob( api_key=agent_kv_key, organization_id=agent_kv_key.organization_id, extractor=v["extractors"][0]["name"], + # The gate above validated these against this org, their expected + # types, and usability -- so what lands here is exactly what the + # run will spend. Ids only, never `adapter_metadata`: this column + # is returned to the caller. + adapters=v["extractors"][0].get("adapters") or {}, pages_total=serializer.pages_total, tags=v["tags"], custom_data=v["custom_data"], @@ -588,6 +678,10 @@ def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): # Guarded on `won` so a lost race (a concurrent cancel or # finalize terminalized it first) does not release a slot that # the winner is still accounting for. + # + # `_never_dispatched` re-reads `dispatched_at` from the DB: the + # `job` here predates `mark_terminal`, and a dispatch can + # complete in that gap (see that function). if _never_dispatched(job): AgentKVConcurrencyLimiter.release( str(job.organization_id), str(job.id) diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index a0a6bbe545..0a537a9708 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -225,7 +225,20 @@ def run_sweep() -> dict: ) else: logger.info("agent-kv sweep: nothing to terminalize") - return {"swept": swept, "timed_out": timed_out} + # `released` is reported, not just logged. Phase 3 counted slot releases + # into a local, logged it, and then returned only the first two counts -- + # so `SweepView` (which returns this dict verbatim) and the scheduler task + # (which logs it) could never show that any slot was recovered. That is the + # same "counted, then discarded before the caller sees it" shape as the + # round-1 finding about this function's return value, reintroduced by the + # phase that was added to fix a different half of it. + # + # It matters operationally: a cancelled job whose executor never called + # back holds a slot against the org's cap, and phase 3 is the only thing + # that frees it. Without this key, "the cap is being hit and the sweep is + # recovering slots" and "the cap is being hit and nothing is recovering + # anything" produce identical output. + return {"swept": swept, "timed_out": timed_out, "released": released} def run_ttl_cleanup() -> dict: diff --git a/backend/agent_kv/migrations/0004_pg_periodic_tasks.py b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py index 046f7558e7..c010a9b488 100644 --- a/backend/agent_kv/migrations/0004_pg_periodic_tasks.py +++ b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py @@ -1,45 +1,64 @@ -"""Schedule the two Agent-KV maintenance periodics. +"""Remove the Agent-KV maintenance periodics -- the CronJob owns the schedule. -Both tasks were registered in ``workers/scheduler/agent_kv_tasks.py`` and both -internal endpoints existed, but nothing anywhere created a row to fire them -- -no ``PgPeriodicTask``, no beat entry, no CronJob. The module's own docstring -said an operator must register them by hand, which in practice means they never -ran. +An earlier revision of this migration SEEDED two ``PgPeriodicTask`` rows +(``agent_kv.sweep`` at ``*/10``, ``agent_kv.ttl_cleanup`` at ``17 3 * * *``) on +the premise that nothing anywhere fired the two maintenance commands -- "no +``PgPeriodicTask``, no beat entry, no CronJob". **That premise was false when it +was written.** ``templates/backend/agent-kv-cronjobs.yaml`` in the cloud chart +already ran the same two management commands, on the same branch, added several +commits earlier. The seeding migration was written in response to a review +finding about the unscheduled sweep without noticing the finding had already +been answered elsewhere in the same change. -What that costs, concretely: +Keeping both would have been worse than either alone: -* ``run_sweep`` never runs, so an OOM-killed executor leaves its job row - non-terminal forever -- ``link_error`` never fires for a killed process -- and - the row holds its concurrency slot until the 6h Redis TTL. Enough of those and - the org's allowance is exhausted and new submits start getting 429s. -* ``run_ttl_cleanup`` never runs, so ``AGENT_KV_RESULT_TTL_DAYS`` is advisory: - every staged customer document and every result stays in the bucket - indefinitely. That is a retention-policy failure, not just a disk-usage one. +* **Two owners for one schedule.** The rows are seeded ``pg_owned: False``, and + the PG scheduler claims only ``WHERE pg_owned AND enabled`` + (``pg_queue/models.py``), so they are inert *today*. But the whole point of + that flag is that a rollout flips it -- and the moment it did, the ``*/10`` + rows and the CronJob's ``*/15`` ticks would both fire, colliding exactly at + ``:30`` and contending on the same job rows. The TTL cadences disagreed by + 24x (daily here, hourly in the chart), so whichever fired would be whichever + owner someone remembered. +* **Only one owner has operational bounds.** The CronJob carries + ``concurrencyPolicy: Forbid`` and ``activeDeadlineSeconds``; a PG periodic has + neither, so a hung sweep under the scheduler has nothing stopping it. +* **The feature only runs where the CronJob is.** This deployment routes + ``table`` only, and the ``agentic_table`` plugin ships in the cloud image. A + pure-OSS install cannot dispatch an Agent-KV job at all, so there is no + stranded row for a sweep to reap there. -Follows the ``dashboard_metrics`` precedent (migrations 0004-0006) rather than -inventing a mechanism: the same ``PgPeriodicTask`` table, the same -``update_or_create`` shape so a re-run is idempotent, and ``pg_owned: False`` so -applying this migration does not itself start firing them -- the rollout flag -decides that, exactly as it does for the metrics periodics. +So the CronJob is the single owner, and this migration deletes the rows rather +than merely stopping at not creating them -- any environment that applied the +seeding revision (dev namespaces did) still has them, and leaving them to be +switched on by a future global ``pg_owned`` flip is precisely the hazard being +removed. -Cadences are deliberately offset from each other and off the hour: the sweep is -cheap and wants to be frequent (a stuck job holds a slot), the TTL cleanup is a -bulk delete that should not land on the same tick as anything else. +**Self-hosted OSS operators who enable the Agent-KV API must schedule +``manage.py agent_kv_sweep`` and ``manage.py agent_kv_ttl_cleanup`` +themselves** (cron, a systemd timer, or their own ``PgPeriodicTask`` rows with +``pg_owned: True``). Without them a stranded job holds its concurrency slot +until the 6h Redis TTL, and ``AGENT_KV_RESULT_TTL_DAYS`` is advisory -- +staged documents and results stay in the bucket indefinitely, which is a +retention failure rather than a disk-usage one. ``docs/agent-kv-api.md`` §12 +carries this in the deploy checklist. + +Reverse re-creates the rows (still inert) so the migration is reversible and a +``migrate agent_kv 0003`` lands back on the previous tree's state exactly. """ from django.db import migrations +#: What the seeding revision wrote, kept ONLY so forward can delete exactly +#: those rows by name and reverse can restore them. Not a live schedule -- +#: the cadences that run are in the cloud chart's `backend.agentKvCronJobs`. PG_PERIODIC_TASKS = [ { "name": "agent_kv_sweep", - # Wire name registered by workers/scheduler/agent_kv_tasks.py. "task_name": "agent_kv.sweep", "queue": "scheduler", "task_args": [], "task_kwargs": {}, - # Every 10 minutes: a stuck job holds a concurrency slot, and the - # handler is idempotent and batch-capped server-side, so a missed tick - # costs nothing and a frequent one is cheap. "cron_string": "*/10 * * * *", }, { @@ -48,19 +67,25 @@ "queue": "scheduler", "task_args": [], "task_kwargs": {}, - # Daily at 03:17 UTC. Off the hour and off the dashboard_metrics - # cleanups (02:00 / 03:00) so a bulk object-store delete does not land - # on the same tick as another bulk job. "cron_string": "17 3 * * *", }, ] -def create_pg_periodic_tasks(apps, schema_editor): +def remove_pg_periodic_tasks(apps, schema_editor): + """Delete the seeded rows. Idempotent: a fresh install matches nothing.""" # snake_case, not the usual `PgPeriodicTask = apps.get_model(...)` Django # idiom: it is a local variable, and sonar's S117 reads the CamelCase name # as a naming violation. The historical-model object is the same either way. periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") + periodic_task.objects.filter( + name__in=[spec["name"] for spec in PG_PERIODIC_TASKS] + ).delete() + + +def create_pg_periodic_tasks(apps, schema_editor): + """Reverse only. Restores the rows the seeding revision created, inert.""" + periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") for spec in PG_PERIODIC_TASKS: periodic_task.objects.update_or_create( name=spec["name"], @@ -74,27 +99,18 @@ def create_pg_periodic_tasks(apps, schema_editor): # endpoints sweep across every organization. "org_id": "", "enabled": True, - # Inert until the rollout flag decides otherwise; never fired by - # applying this migration. Same posture as dashboard_metrics. "pg_owned": False, }, ) -def remove_pg_periodic_tasks(apps, schema_editor): - periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") - periodic_task.objects.filter( - name__in=[spec["name"] for spec in PG_PERIODIC_TASKS] - ).delete() - - class Migration(migrations.Migration): dependencies = [ ("agent_kv", "0003_agentkvjob_cleanup_failed_at"), - # The table this seeds. + # The table this operates on. ("pg_queue", "0003_pgperiodictask"), ] operations = [ - migrations.RunPython(create_pg_periodic_tasks, remove_pg_periodic_tasks), + migrations.RunPython(remove_pg_periodic_tasks, create_pg_periodic_tasks), ] diff --git a/backend/agent_kv/migrations/0006_agentkvjob_adapters.py b/backend/agent_kv/migrations/0006_agentkvjob_adapters.py new file mode 100644 index 0000000000..e3a1b0d702 --- /dev/null +++ b/backend/agent_kv/migrations/0006_agentkvjob_adapters.py @@ -0,0 +1,30 @@ +"""Record which platform adapters a job ran on. + +The `table` extractor resolves the CALLER's own adapters rather than operator +env credentials, so adapter choice became a per-request, cost-bearing decision. +Before this column the chosen ids reached `executor_params` and nowhere else -- +not the job row, not the status document, not `usage_summary` -- so the first +question in any billing dispute ("which model did job X use?") needed a join +against `usage_v2` on `run_id` that the API cannot perform and the customer +cannot see. + +Additive and nullable-by-default (`{}`), so existing rows are untouched: they +predate caller-supplied adapters and genuinely ran on env credentials, which +`{}` is the honest representation of. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("agent_kv", "0005_agentkvjob_extractor_choices"), + ] + + operations = [ + migrations.AddField( + model_name="agentkvjob", + name="adapters", + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index 149a3136fe..45df84d8dd 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -106,6 +106,25 @@ class AgentKVJob(DefaultOrganizationMixin, BaseModel): stage = models.CharField(max_length=32, blank=True, default="") stages = models.JSONField(default=dict, blank=True) pages_total = models.IntegerField(null=True, blank=True) + #: Which platform adapters this run was dispatched with, by role -- + #: ``{"llm": "", "lite_llm": "", "x2text": ""}``. + #: + #: Recorded because adapter choice is now a per-request, caller-controlled, + #: COST-BEARING decision (the `table` extractor runs on the submitter's own + #: adapters, not operator env credentials). Without it the ids reached + #: `executor_params` and nothing else: neither the status document nor + #: `usage_summary` reported them, so "which model did job X use?" -- the + #: first question in any billing dispute -- could only be answered by + #: joining `usage_v2` on `run_id`, which the API cannot do and a customer + #: cannot see at all. + #: + #: Empty for an env-configured extractor (`kv`), which is why it is a dict + #: with a `{}` default rather than three nullable columns: the two + #: credential models coexist, one per extractor. + #: + #: Ids only. Deliberately never `adapter_metadata` -- that is where the + #: provider credentials live, and this column is returned to the caller. + adapters = models.JSONField(default=dict, blank=True) input_ref = models.CharField(max_length=512, blank=True, default="") result_ref = models.CharField(max_length=512, blank=True, default="") usage_summary = models.JSONField(null=True, blank=True) diff --git a/backend/agent_kv/tests/test_adapter_scoping.py b/backend/agent_kv/tests/test_adapter_scoping.py index 704fd849e6..a8984a8cad 100644 --- a/backend/agent_kv/tests/test_adapter_scoping.py +++ b/backend/agent_kv/tests/test_adapter_scoping.py @@ -26,6 +26,7 @@ import os import uuid +from unittest import mock import django from django.apps import apps @@ -38,7 +39,12 @@ from account_v2.models import Organization # noqa: E402 from adapter_processor_v2.models import AdapterInstance # noqa: E402 -from agent_kv.execution_views import _lookup_adapter # noqa: E402 +from rest_framework.exceptions import ValidationError # noqa: E402 + +from agent_kv.execution_views import ( # noqa: E402 + _lookup_adapter, + _resolved_adapters, +) from utils.user_context import UserContext # noqa: E402 @@ -110,3 +116,208 @@ def test_the_ambient_manager_really_does_hide_it(self): "`.objects` is expected to hide the adapter here -- that is the " "whole reason `_lookup_adapter` uses `_base_manager`", ) + + +class TestResolvedAdaptersAgainstRealRows(TestCase): + """`_resolved_adapters` end-to-end against the database, not against mocks. + + `test_submit_view.py` patches `ev._lookup_adapter`, so every property the + gate reads off an adapter -- `adapter_type`, `is_usable`, `is_available` -- + is only ever compared to a value the stand-in chose for itself. That is + enough to test the gate's branching and nothing about whether the branches + match the real columns. These rows get their flags from the model's own + defaults. + + It is also the only place the refusal-oracle property can honestly be + tested: "not yours" and "no such adapter" have to come from two GENUINELY + different database states, which a single mocked lookup cannot produce. + """ + + def setUp(self): + self.org = Organization.objects.create( + display_name="Acme", organization_id="org_acme", name="acme" + ) + self.other = Organization.objects.create( + display_name="Other", organization_id="org_other", name="other" + ) + self.key = mock.Mock(organization_id=self.org.id) + UserContext.set_organization_identifier(None) + + def _adapter(self, *, org=None, adapter_type="LLM", **kwargs): + return AdapterInstance.objects.create( + adapter_name=f"a-{uuid.uuid4().hex[:8]}", + adapter_id="anthropic|abc", + adapter_type=adapter_type, + adapter_metadata={}, + organization=org or self.org, + **kwargs, + ) + + def _entry(self, **roles): + return {"name": "table", "adapters": {r: str(v) for r, v in roles.items()}} + + def _full_entry(self, **overrides): + roles = { + "llm": self._adapter().id, + "lite_llm": self._adapter().id, + "x2text": self._adapter(adapter_type="X2TEXT").id, + } + roles.update(overrides) + return self._entry(**roles) + + def test_a_fully_valid_set_of_the_org_s_own_adapters_resolves(self): + """The happy path, with every flag coming from the DB's own defaults. + + `is_usable` and `is_available` both default True at the model, so this + also pins that the two new refusals do not reject ordinary adapters + nobody has touched. + """ + entry = self._full_entry() + + self.assertEqual(_resolved_adapters(entry, self.key), entry["adapters"]) + + def test_a_wrong_type_is_refused_against_a_real_row(self): + """An X2TEXT id in the `llm` slot. + + Previously asserted only against a mock that was told to report + `adapter_type="X2TEXT"`; this reads the real column, so the comparison + against `AdapterTypes.LLM.value` is pinned to the value the DB stores. + """ + entry = self._full_entry(llm=self._adapter(adapter_type="X2TEXT").id) + + with self.assertRaises(ValidationError) as caught: + _resolved_adapters(entry, self.key) + + self.assertIn("expected 'LLM'", str(caught.exception)) + + def test_an_exhausted_trial_adapter_is_refused(self): + """A1. `is_usable=False` is how billing cuts off a frictionless trial. + + The platform service returns the credentials regardless, so this gate + is the only thing that stops an operator-funded extraction here. + """ + entry = self._full_entry(llm=self._adapter(is_usable=False).id) + + with self.assertRaises(ValidationError) as caught: + _resolved_adapters(entry, self.key) + + self.assertIn("exhausted", str(caught.exception)) + + def test_a_deprecated_adapter_is_refused(self): + entry = self._full_entry(llm=self._adapter(is_available=False).id) + + with self.assertRaises(ValidationError) as caught: + _resolved_adapters(entry, self.key) + + self.assertIn("deprecated", str(caught.exception)) + + def test_another_org_s_adapter_and_a_nonexistent_one_refuse_identically(self): + """The oracle property, from two genuinely different DB states. + + If these two messages ever diverge, `POST /agent-kv/` becomes an oracle + for which adapter UUIDs are real in OTHER organizations: a caller + brute-forcing ids learns "exists but not yours" from "does not exist". + + The mocked version of this test could not fail -- one patched lookup + returns None for both cases, so it compared one branch's message to + itself. Here one id is a real row owned by `self.other` and the other + is a UUID no row has. + """ + foreign = self._adapter(org=self.other).id + nowhere = uuid.uuid4() + + messages = [] + for adapter_id in (foreign, nowhere): + with self.assertRaises(ValidationError) as caught: + _resolved_adapters(self._full_entry(llm=adapter_id), self.key) + messages.append(str(caught.exception)) + + self.assertEqual( + messages[0], + messages[1], + "a foreign adapter and a nonexistent one must be indistinguishable; " + f"got {messages[0]!r} vs {messages[1]!r}", + ) + + def test_no_adapters_requested_is_a_no_op(self): + """The `kv` shape: env-configured extractors send `{}` and must pass.""" + self.assertEqual(_resolved_adapters({"name": "kv", "adapters": {}}, self.key), {}) + + +class TestTheJobRowRecordsWhatItSpent(TestCase): + """The adapters a run was dispatched with must be recoverable AFTERWARDS. + + Adapter choice is per-request, caller-controlled and cost-bearing on the + `table` path. Before the `adapters` column the chosen ids reached + `executor_params` and nothing else -- not the job row, not the status + document, not `usage_summary` -- so "which model did job X use?", the first + question in any billing dispute, needed a `usage_v2` join on `run_id` that + the API cannot perform and the customer cannot see. + + DB-backed because the point is that the value SURVIVES the request. + """ + + def setUp(self): + self.org = Organization.objects.create( + display_name="Acme", organization_id="org_acme", name="acme" + ) + UserContext.set_organization_identifier(None) + + def _job(self, **kwargs): + from agent_kv.models import AgentKVJob, JobExtractor + + job = AgentKVJob( + organization_id=self.org.id, + extractor=JobExtractor.TABLE, + **kwargs, + ) + job.save() + return job + + def test_the_adapters_round_trip_through_the_database(self): + adapters = { + "llm": str(uuid.uuid4()), + "lite_llm": str(uuid.uuid4()), + "x2text": str(uuid.uuid4()), + } + + job = self._job(adapters=adapters) + job.refresh_from_db() + + assert job.adapters == adapters + + def test_the_status_document_reports_them(self): + from agent_kv.execution_views import _status_document + + adapters = {"llm": str(uuid.uuid4())} + doc = _status_document(self._job(adapters=adapters)) + + assert doc["adapters"] == adapters, doc + + def test_a_job_with_no_adapters_reports_an_empty_dict_not_null(self): + """`kv` is env-configured and names none, and rows predating the column + genuinely ran on operator credentials -- `{}` is the honest value for + both, and a stable type is easier for a client than `null | object`. + """ + from agent_kv.execution_views import _status_document + + job = self._job() + + assert job.adapters == {} + assert _status_document(job)["adapters"] == {} + + def test_no_adapter_metadata_is_ever_stored_on_the_job(self): + """The column is returned to the caller, so it must carry ids only. + + `AdapterInstance.adapter_metadata` holds the PROVIDER CREDENTIALS. A + future change that widened this field to "the adapter" rather than "its + id" would publish those to whoever submitted the job. + """ + adapters = {"llm": str(uuid.uuid4()), "x2text": str(uuid.uuid4())} + job = self._job(adapters=adapters) + job.refresh_from_db() + + for role, value in job.adapters.items(): + assert isinstance(value, str), (role, value) + # A bare UUID string, not a serialized adapter. + uuid.UUID(value) diff --git a/backend/agent_kv/tests/test_cross_repo_stage_name.py b/backend/agent_kv/tests/test_cross_repo_stage_name.py index 00255a7169..fe69212c1b 100644 --- a/backend/agent_kv/tests/test_cross_repo_stage_name.py +++ b/backend/agent_kv/tests/test_cross_repo_stage_name.py @@ -41,3 +41,55 @@ def test_the_table_extractor_reports_exactly_one_stage(): be recorded as missing rather than pending, for every job.""" assert TABLE_STAGE_NAMES == [CLOUD_STAGE_TABLE_EXTRACTION] assert TABLE_EXTRACTOR_NAME == "table" + + +# --------------------------------------------------------------------------- +# Second cross-repo lock in this pair: the stage-STATUS allowlist. +# +# Same shape as the stage-name lock above and the same class of silent drift, +# but one layer along. `StageReportView` rejects a status outside +# `_VALID_STAGE_STATUSES` with a 400 -- and the cloud reporter +# (`extraction_seams.progress.StageReporter.report`) SWALLOWS 400s by design, +# because its contract is that a failed progress report must never fail a +# paid run. So if the two sets drift, every affected job completes and bills +# normally while its reported stage stays frozen at the last value that +# happened to be accepted. +# +# The cloud side already carried a `!! CROSS-REPO CONSTANT !!` banner on its +# copy with nothing asserting against this one. The cloud twin of this test is +# `workers/plugins/extraction_seams/tests/test_progress.py`. +# --------------------------------------------------------------------------- + +#: Must equal ``VALID_STAGE_STATUSES`` in the cloud repo's +#: ``workers/plugins/extraction_seams/src/progress.py``. Change one, change +#: both, in the same PR. +#: +#: A literal, not an import: the two repos are separate checkouts and only meet +#: in the merged tree, so an import would make this pass vacuously wherever the +#: other side is absent -- exactly the environments where drift is introduced. +CLOUD_VALID_STAGE_STATUSES = frozenset({"running", "done"}) + + +def test_the_accepted_stage_statuses_match_what_the_executor_sends(): + from agent_kv.internal_views import _VALID_STAGE_STATUSES + + assert _VALID_STAGE_STATUSES == CLOUD_VALID_STAGE_STATUSES, ( + "_VALID_STAGE_STATUSES no longer equals VALID_STAGE_STATUSES in the " + "cloud repo's workers/plugins/extraction_seams/src/progress.py. Stage " + "reports will be rejected with a 400 that the cloud reporter swallows, " + "so jobs will complete and bill while their reported stage freezes. " + "Update both sides, and both twins of this test, together." + ) + + +def test_a_terminal_outcome_is_not_an_accepted_stage_status(): + """Why the set is only two values, asserted rather than described. + + Terminal outcomes travel on finalize. `"failed"` as a stage status is the + case that actually shipped once, and accepting it here would let a job + report a terminal stage while its row stayed non-terminal. + """ + from agent_kv.internal_views import _VALID_STAGE_STATUSES + + for terminal in ("failed", "completed", "cancelled", "timed_out"): + assert terminal not in _VALID_STAGE_STATUSES, terminal diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index 4416027756..48d34b34e6 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -308,6 +308,7 @@ def test_cancel_win_releases_concurrency_slot(m_keys, m_jobs, m_mark_terminal, m job = AgentKVJob(status=JobStatus.PENDING) job.organization_id = "org1" m_jobs.get.return_value = job + _dispatched_in_db(m_jobs, False) resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) @@ -315,6 +316,17 @@ def test_cancel_win_releases_concurrency_slot(m_keys, m_jobs, m_mark_terminal, m m_release.assert_called_once_with("org1", str(job.id)) +def _dispatched_in_db(m_jobs, dispatched: bool) -> None: + """Wire the DB re-read `_never_dispatched` performs. + + It no longer reads `job.dispatched_at` off the in-memory row -- that row + predates `mark_terminal`, and a dispatch completing in that gap made the + cancel release a slot whose executor was still running (round-2 C1). It now + asks the database, so these tests have to say what the database would + answer. + """ + m_jobs.filter.return_value.exists.return_value = dispatched + # --------------------------------------------------------------------------- # (6c) cancel that LOSES the guard (job already terminal) must NOT release -- # whoever terminalized it (finalize callback / a prior cancel) owns the slot. @@ -488,6 +500,7 @@ def test_delete_on_a_never_dispatched_job_DOES_release_the_slot( job.organization_id = "org1" job.dispatched_at = None m_jobs.get.return_value = job + _dispatched_in_db(m_jobs, False) resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) @@ -806,6 +819,7 @@ def test_cancel_on_a_never_dispatched_job_DOES_release_the_slot( job.organization_id = "org1" job.dispatched_at = None m_jobs.get.return_value = job + _dispatched_in_db(m_jobs, False) resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) @@ -813,15 +827,58 @@ def test_cancel_on_a_never_dispatched_job_DOES_release_the_slot( m_release.assert_called_once_with("org1", str(job.id)) -def test_a_dispatched_but_still_pending_job_is_not_treated_as_undispatched(): - """`dispatched_at` and `status` are checked together on purpose. - - Post-enqueue bookkeeping can fail after the task is already on the queue - (dispatch.py wraps it precisely because losing it must not fail the - dispatch), leaving a row that still reads PENDING while an executor runs. - Releasing that slot would over-subscribe exactly as the RUNNING case did. +@mock.patch.object(ev, "dispatch_cancelled_webhook") +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_a_cancel_that_raced_a_dispatch_does_NOT_release_the_slot( + m_keys, m_jobs, m_mark_terminal, m_release, m_hook +): + """Round-2 C1. The in-memory row says undispatched; the database disagrees. + + The interleaving: the cancel reads a PENDING row with `dispatched_at=NULL`, + then `dispatch_job` passes its own terminal re-read, enqueues, and stamps + `dispatched_at` / DISPATCHED. The cancel's `mark_terminal` still wins + (DISPATCHED is not terminal), and the old check -- reading + `job.dispatched_at` off the snapshot -- answered True and released a slot + whose executor was running and billing. + + That is a smaller instance of the very over-release bug the narrowing was + written to fix. The row here is deliberately left looking undispatched, so + the ONLY thing that can produce the right answer is the DB re-read. """ + m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.PENDING) + job.organization_id = "org1" + job.dispatched_at = None # the stale snapshot, as the cancel path sees it + m_jobs.get.return_value = job + _dispatched_in_db(m_jobs, True) # what actually happened meanwhile + + resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) + + assert resp.status_code == 200 + assert not m_release.called, ( + "released a slot for a job that HAD been dispatched -- the executor is " + "still running and its finalize callback owns the slot" + ) + # The caller is still told, since cancellation never reaches finalize. + assert m_hook.called + + +@mock.patch.object(AgentKVJob, "objects") +def test_the_undispatched_check_asks_the_database_not_the_row(m_jobs): + """Pins the mechanism, so the C1 regression cannot return quietly. + + If `_never_dispatched` goes back to reading `job.dispatched_at`, this fails: + the row says dispatched (which the old code would honour) while the DB says + it is not, and only the DB answer is correct after a `mark_terminal`. + """ + job = AgentKVJob(status=JobStatus.CANCELLED) job.dispatched_at = timezone.now() + _dispatched_in_db(m_jobs, False) + + assert ev._never_dispatched(job) is True - assert not ev._never_dispatched(job) + _dispatched_in_db(m_jobs, True) + assert ev._never_dispatched(job) is False diff --git a/backend/agent_kv/tests/test_periodic_tasks_migration.py b/backend/agent_kv/tests/test_periodic_tasks_migration.py index 662db83338..f03c4a7c85 100644 --- a/backend/agent_kv/tests/test_periodic_tasks_migration.py +++ b/backend/agent_kv/tests/test_periodic_tasks_migration.py @@ -1,22 +1,35 @@ -"""The two maintenance periodics must be scheduled, not merely registered. - -Both tasks were registered in ``workers/scheduler/agent_kv_tasks.py`` and both -internal endpoints existed, but nothing anywhere created a row to fire them. -"Wired but unscheduled" is an invisible failure: the code looks complete, the -endpoints respond to a manual call, and the only symptoms are that stuck jobs -keep their concurrency slots until a 6h Redis TTL and that customer documents -are retained forever despite a documented ``AGENT_KV_RESULT_TTL_DAYS``. - -These assertions pin the migration's declared rows against the wire names the -worker actually registers, so renaming one side without the other fails here -rather than silently stopping the schedule. - -Reported by Greptile on PR #2317, and as 2.3 in the branch review. +"""One owner for the two maintenance schedules, and it is the CronJob. + +History, because the test's shape only makes sense with it: an earlier revision +of migration 0004 SEEDED two ``PgPeriodicTask`` rows to fire +``agent_kv.sweep`` and ``agent_kv.ttl_cleanup``, answering a review finding +about the sweep never running. The finding was real; the fix was redundant. +``templates/backend/agent-kv-cronjobs.yaml`` in the cloud chart already ran the +same two management commands, added earlier on the same branch, so the +migration's stated premise -- "no ``PgPeriodicTask``, no beat entry, no +CronJob" -- was false about its own tree. + +The rows were seeded ``pg_owned: False`` and the PG scheduler claims only +``WHERE pg_owned AND enabled``, so nothing double-fired yet. That is the +dangerous kind of correct: the flag exists to be flipped, and a flip would have +put a ``*/10`` sweep against the chart's ``*/15`` -- colliding at ``:30``, +contending on the same job rows -- with TTL cadences disagreeing 24x. + +So 0004 now DELETES those rows and the CronJob is the sole owner. What these +tests defend is that decision, in the two ways it can be undone: someone +re-pointing the forward operation at the seeding function, or someone dropping +the reverse and making the migration one-way. + +The name/queue assertions are kept rather than deleted -- they still guard the +reverse path, and the same wire names are what the CronJob's management +commands resolve to. """ import importlib +import inspect import pytest +from django.db import migrations # The module name starts with a digit, so it cannot be a plain `import`. MIGRATION = importlib.import_module("agent_kv.migrations.0004_pg_periodic_tasks") @@ -33,18 +46,86 @@ def _specs() -> dict: return {spec["name"]: spec for spec in MIGRATION.PG_PERIODIC_TASKS} -def test_both_maintenance_tasks_are_scheduled(): +def _run_python_ops() -> list: + return [ + op + for op in MIGRATION.Migration.operations + if isinstance(op, migrations.RunPython) + ] + + +# --------------------------------------------------------------------------- +# The ownership decision. +# --------------------------------------------------------------------------- + + +def test_the_migration_still_has_a_runpython_operation(): + """Guards the empty-operations failure mode. + + Every other assertion in this file reads `PG_PERIODIC_TASKS` or a function's + source, so `operations = []` would leave the whole suite green while the + migration did nothing at all -- applying cleanly and leaving the seeded rows + in place in every environment that already has them. + """ + assert len(_run_python_ops()) == 1, MIGRATION.Migration.operations + + +def test_the_forward_operation_removes_the_rows_rather_than_seeding_them(): + """The decision itself. + + If this fails, someone has re-pointed forward at `create_pg_periodic_tasks` + and there are two owners for one schedule again. Read the migration's + docstring before changing it: the collision is at `:30`, and only the + CronJob side carries `concurrencyPolicy: Forbid` and a deadline. + """ + assert _run_python_ops()[0].code is MIGRATION.remove_pg_periodic_tasks + + +def test_the_migration_is_reversible(): + """`migrate agent_kv 0003` must land back on the previous tree's state. + + An irreversible data migration here would mean a rollback of this release + leaves an environment with no rows and no way to restore them except by + hand -- and the seeding revision is what the previous release shipped. + """ + op = _run_python_ops()[0] + + assert op.reverse_code is MIGRATION.create_pg_periodic_tasks + assert op.reverse_code is not migrations.RunPython.noop + + +def test_removal_is_idempotent_and_targets_only_the_two_seeded_names(): + """A fresh install has no such rows, and re-running must still succeed. + + Equally important: the delete is scoped by name. An unscoped + `PgPeriodicTask.objects.all().delete()` here would wipe the + dashboard_metrics periodics this migration sits alongside. + """ + source = inspect.getsource(MIGRATION.remove_pg_periodic_tasks) + + assert "name__in=" in source, "the delete must be scoped by name" + assert ".all()" not in source + + +# --------------------------------------------------------------------------- +# The reverse path's contents, and the names the CronJob commands share. +# --------------------------------------------------------------------------- + + +def test_both_maintenance_tasks_are_accounted_for(): assert set(_specs()) == {"agent_kv_sweep", "agent_kv_ttl_cleanup"} -def test_every_scheduled_row_points_at_a_task_the_worker_registers(): +def test_every_row_points_at_a_task_the_worker_registers(): """A row naming a task nothing registers is a schedule that fires into - nothing -- the same invisible failure, one layer along. + nothing. Still live for the reverse path, and the same wire names back the + `agent_kv_sweep` / `agent_kv_ttl_cleanup` management commands the CronJob + invokes. """ declared = {spec["task_name"] for spec in MIGRATION.PG_PERIODIC_TASKS} assert declared == WORKER_TASK_NAMES, ( - f"migration schedules {sorted(declared)} but workers/scheduler/" + f"migration names {sorted(declared)} but workers/scheduler/" f"agent_kv_tasks.py registers {sorted(WORKER_TASK_NAMES)}" ) @@ -54,22 +135,6 @@ def test_rows_are_scheduled_onto_the_scheduler_queue(name): assert _specs()[name]["queue"] == "scheduler" -def test_rows_are_platform_wide_and_enabled(): - """Both internal endpoints sweep across every organization (spec §5.4), so a - non-empty `org_id` would scope them to one tenant and silently leave every - other org unswept. - - Asserted on the writer rather than the spec table: `org_id`/`enabled` are - set once in `defaults`, not per row. - """ - import inspect - - source = inspect.getsource(MIGRATION.create_pg_periodic_tasks) - - assert '"org_id": ""' in source - assert '"enabled": True' in source - - @pytest.mark.parametrize("name", ["agent_kv_sweep", "agent_kv_ttl_cleanup"]) def test_cron_strings_are_five_field_and_parseable(name): """A malformed cron string is accepted by the column and then never fires.""" @@ -78,28 +143,24 @@ def test_cron_strings_are_five_field_and_parseable(name): assert len(fields) == 5, _specs()[name]["cron_string"] -def test_the_sweep_runs_far_more_often_than_the_ttl_cleanup(): - """Not cosmetic: a stuck job holds a concurrency slot, so the sweep has to - be frequent, while the TTL cleanup is a bulk object-store delete that should - not run on a tight loop. If these ever converge, one of them is wrong. +def test_the_restored_rows_are_inert(): + """`pg_owned: False` on the reverse path too. + + A reverse that restored them ENABLED would turn a rollback into the + double-fire this migration exists to prevent. """ - sweep = _specs()["agent_kv_sweep"]["cron_string"] - ttl = _specs()["agent_kv_ttl_cleanup"]["cron_string"] + source = inspect.getsource(MIGRATION.create_pg_periodic_tasks) - assert sweep.startswith("*/"), f"sweep should be interval-based, got {sweep!r}" - assert not ttl.startswith("*/"), f"ttl cleanup should be daily, got {ttl!r}" + assert '"pg_owned": False' in source + assert "update_or_create" in source, "re-running must stay idempotent" -def test_rows_are_created_inert(): - """`pg_owned: False` means applying the migration does not itself start - firing them -- the rollout flag decides, same posture as the - dashboard_metrics periodics this follows. +def test_restored_rows_are_platform_wide(): + """Both internal endpoints sweep across every organization (spec §5.4), so a + non-empty `org_id` would scope them to one tenant and silently leave every + other org unswept. """ - # The flag lives in the migration's writer, not its spec table, so assert on - # the source rather than re-running the RunPython against a live DB. - import inspect - source = inspect.getsource(MIGRATION.create_pg_periodic_tasks) - assert '"pg_owned": False' in source - assert "update_or_create" in source, "re-running the migration must be idempotent" + assert '"org_id": ""' in source + assert '"enabled": True' in source diff --git a/backend/agent_kv/tests/test_queue_wiring_is_derived.py b/backend/agent_kv/tests/test_queue_wiring_is_derived.py new file mode 100644 index 0000000000..e2c9109e82 --- /dev/null +++ b/backend/agent_kv/tests/test_queue_wiring_is_derived.py @@ -0,0 +1,297 @@ +"""Fleet queue wiring, DERIVED from `EXTRACTOR_ROUTES` rather than literal-pinned. + +`workers/tests/test_queue_consumer_wiring.py` guards the same property with +hardcoded queue names (`UNSERVED_KV_QUEUE`, `AGENT_TABLE_QUEUE`). That works +today and catches a real, expensive failure -- but it cannot survive the change +it most needs to survive. Re-enabling the `kv` extractor means adding one entry +to `EXTRACTOR_ROUTES`, at which point `celery_executor_agentic_kv` flips from +"must NOT be advertised" to "must be served", and the literal-pinned assertion +has to be *deleted* to make the suite pass. An assertion whose correct response +to a change is deletion is not protecting anything at that moment. + +This suite derives both directions from the routing table, so the same change +flips the expectation automatically: + +* Every executor in `EXTRACTOR_ROUTES` must have its `celery_executor_` + queue served by every fleet configuration. Unserved means jobs are accepted + and sit in DISPATCHED forever -- `enqueue_task` succeeds, rows land in + `pg_queue_message`, nothing errors at the producer. +* Every executor this module names but deliberately does NOT route + (`EXECUTOR_NAME` = `agentic_kv`, kept dormant by the carve-out) must NOT be + advertised anywhere. Same silent failure from the other side. + +Lives in the backend suite because that is where `EXTRACTOR_ROUTES` is +importable; the fleet configs it reads are plain text. The workers-side suite +keeps its literal assertions as a cheap second opinion -- two independent +statements of the same fact, which is the point. + +**Every path is guarded by `pytest.skip` when absent.** A cross-tree read that +assumed a file existed has reddened CI on this branch once already: the backend +test venv runs from `backend/`, and nothing guarantees a packaging step kept +`docker/` or `workers/` alongside it. +""" + +import re +from pathlib import Path + +import pytest +import yaml + +from agent_kv.constants import ( + EXECUTOR_NAME, + EXTRACTOR_ROUTES, + TABLE_EXECUTOR_NAME, +) + +REPO_ROOT = Path(__file__).resolve().parents[3] +DEV_COMPOSE = REPO_ROOT / "docker" / "docker-compose.yaml" +TEST_COMPOSE = REPO_ROOT / "tests" / "compose" / "docker-compose.test.yaml" +RUN_WORKER = REPO_ROOT / "workers" / "run-worker.sh" +RUN_WORKER_DOCKER = REPO_ROOT / "workers" / "run-worker-docker.sh" + +PG_QUEUE_VAR = "WORKER_PG_QUEUE_CONSUMER_QUEUE" + +#: Every executor name this module knows about. The ones in `EXTRACTOR_ROUTES` +#: must be served; the rest must not be advertised. Derived, so adding a route +#: moves a name from one set to the other with no edit here. +ALL_KNOWN_EXECUTORS = {EXECUTOR_NAME, TABLE_EXECUTOR_NAME} + + +def _queue_name(executor: str) -> str: + """How `pg_queue` derives a queue from an executor name. + + Mirrors the dispatcher's own derivation. If that convention changes, every + assertion here goes stale together rather than one at a time. + """ + return f"celery_executor_{executor}" + + +def _routed_executors() -> set[str]: + return {executor for executor, _operation in EXTRACTOR_ROUTES.values()} + + +def _unrouted_executors() -> set[str]: + return ALL_KNOWN_EXECUTORS - _routed_executors() + + +def _require(path: Path) -> str: + if not path.is_file(): + pytest.skip(f"{path} is not present in this tree") + return path.read_text() + + +def _queues(raw: str) -> set[str]: + """Queue names from a consumer list, ignoring a `${VAR:-default}` wrapper.""" + inner = re.sub(r"^\$\{[^:}]+:-(.*)\}$", r"\1", raw.strip()) + return {q.strip() for q in inner.split(",") if q.strip()} + + +def _compose_executor_queues(path: Path) -> set[str]: + doc = yaml.safe_load(_require(path)) + env = (doc.get("services", {}).get("worker-pg-executor") or {}).get( + "environment" + ) or [] + if isinstance(env, dict): + values = {k: str(v) for k, v in env.items()} + else: + values = {} + for item in env: + key, _, value = str(item).partition("=") + values[key] = value + raw = values.get(PG_QUEUE_VAR) + assert raw is not None, ( + f"{path.name}: worker-pg-executor sets no {PG_QUEUE_VAR}, so the " + f"running pg-queue-consumer falls back to a default of unknown content" + ) + return _queues(raw) + + +def _shell_role_queues(path: Path, pattern: str) -> set[str]: + match = re.search(pattern, _require(path)) + assert match, f"could not find {pattern!r} in {path.name}" + return _queues(match.group(1)) + + +#: Every place a fleet declares which executor queues it drains. The two shell +#: runners are included because a queue missing from a launcher is exactly as +#: undrained as one missing from compose -- and `run-worker-docker.sh` was +#: guarded by nothing at all. +def _all_executor_fleets() -> dict: + return { + "dev compose": lambda: _compose_executor_queues(DEV_COMPOSE), + "e2e test compose": lambda: _compose_executor_queues(TEST_COMPOSE), + "run-worker.sh": lambda: _shell_role_queues( + RUN_WORKER, r'\["\$PG_ROLE_EXECUTOR"\]="executor;([^"]+)"' + ), + } + + +@pytest.mark.parametrize("fleet", sorted(_all_executor_fleets())) +def test_every_routed_executor_has_a_consumer(fleet): + """The forward direction, derived. + + A route with no consumer accepts work and drains nothing, silently -- the + failure that cost this team ~30 hours of firings with zero executions. + """ + served = _all_executor_fleets()[fleet]() + expected = {_queue_name(e) for e in _routed_executors()} + + missing = expected - served + assert not missing, ( + f"{fleet} serves none of {sorted(missing)}, but EXTRACTOR_ROUTES " + f"dispatches there. Jobs will be accepted and sit in DISPATCHED " + f"forever with no error at the producer." + ) + + +@pytest.mark.parametrize("fleet", sorted(_all_executor_fleets())) +def test_no_fleet_advertises_an_unrouted_executor(fleet): + """The inverse direction, derived. + + `agentic_kv` is the live case: this deployment ships no such plugin, so a + fleet listing its queue would accept work nothing can drain. When `kv` is + re-enabled by adding its `EXTRACTOR_ROUTES` entry, this expectation inverts + on its own and `test_every_routed_executor_has_a_consumer` starts requiring + the queue instead -- which is the whole reason this is derived. + """ + served = _all_executor_fleets()[fleet]() + forbidden = {_queue_name(e) for e in _unrouted_executors()} + + advertised = forbidden & served + assert not advertised, ( + f"{fleet} advertises {sorted(advertised)}, but no EXTRACTOR_ROUTES " + f"entry dispatches there and this deployment carries no such plugin. " + f"Remove it, or add the route and ship the plugin." + ) + + +def test_the_derivation_is_not_vacuous(): + """Both sets must be non-empty, or the two tests above assert nothing. + + If `EXTRACTOR_ROUTES` were emptied, `test_every_routed_executor_...` would + pass with an empty expectation -- green while nothing was wired. If every + known executor were routed, the inverse test would pass trivially. This is + the guard on the guards. + """ + assert _routed_executors(), "no routed executors: the forward test is vacuous" + assert _unrouted_executors(), ( + "every known executor is routed, so the inverse test is vacuous. If " + "`kv` was deliberately re-enabled, that is correct -- delete this " + "assertion and say so." + ) + + +# --------------------------------------------------------------------------- +# The callback queue, across BOTH launchers. +# +# `run-worker-docker.sh` carries its own `["ide_callback"]= +# "ide_callback,agent_kv_callback"` map and was guarded by nothing: dropping +# `agent_kv_callback` there passed every test in the tree while a +# docker-launched fleet drained no terminal callbacks, leaving every job +# RUNNING forever. +# --------------------------------------------------------------------------- + +CALLBACK_QUEUE = "agent_kv_callback" + + +def test_the_docker_launcher_drains_the_terminal_callback_queue(): + """The launcher the workers-side suite does not cover.""" + queues = _shell_role_queues( + RUN_WORKER_DOCKER, r'\["ide_callback"\]="([^"]+)"' + ) + + assert CALLBACK_QUEUE in queues, ( + f"run-worker-docker.sh's ide_callback role omits {CALLBACK_QUEUE}; a " + f"docker-launched fleet would run jobs to completion and never " + f"terminalize them -- finalize is what persists the result, deletes " + f"the staged input, releases the slot and fires the webhook." + ) + + +def test_the_host_launcher_drains_the_terminal_callback_queue(): + """Already covered on the workers side; repeated here so the two launchers + are asserted side by side and a reader can see neither is missing. + """ + queues = _shell_role_queues( + RUN_WORKER, r'\["\$PG_ROLE_IDE_CALLBACK"\]="ide_callback;([^"]+)"' + ) + + assert CALLBACK_QUEUE in queues + + +# --------------------------------------------------------------------------- +# The billing backstop, DERIVED from `EXTRACTOR_ROUTES` the same way. +# +# `executor.tasks._LLM_BEARING_OPS` is the set of operations that must log when +# a successful run emits no usage records. That log line is the ONLY signal of +# total billing loss: the cloud `flush()` returns an empty list rather than +# raising, so "the billing chain broke" and "this job legitimately made no LLM +# calls" are otherwise indistinguishable. An op missing from the set is a +# missing ALARM, and the symptom is silence. +# +# `workers/tests/test_llm_bearing_ops.py` guards it against a hand-maintained +# `PAID_OPERATIONS` twin, which enforces symmetry between two literals -- so it +# can only fail when the copies DISAGREE, never when both are wrong together. A +# new paid extractor added to `EXTRACTOR_ROUTES` and declared in neither list +# ships with no billing alarm and nothing goes red. +# +# Every operation this API routes drives LLM calls by construction -- that is +# what an Agent-KV extractor IS -- so the set can be derived rather than +# declared. +# +# Read as TEXT and parsed with `ast`, not imported: `executor.tasks` is a +# workers package and is not importable from the backend's test venv (the +# mirror of why the workers-side suite cannot import `EXTRACTOR_ROUTES`). An +# import would make this pass vacuously in exactly the environment where the +# drift happens. Skipped if the file is absent from this tree. +# --------------------------------------------------------------------------- + +EXECUTOR_TASKS = REPO_ROOT / "workers" / "executor" / "tasks.py" + + +def _llm_bearing_ops_literal() -> set[str]: + """Pull `_LLM_BEARING_OPS`' string members out of the source.""" + import ast + + tree = ast.parse(_require(EXECUTOR_TASKS)) + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + targets = [t.id for t in node.targets if isinstance(t, ast.Name)] + if "_LLM_BEARING_OPS" not in targets: + continue + return { + element.value + for element in ast.walk(node.value) + if isinstance(element, ast.Constant) and isinstance(element.value, str) + } + pytest.fail(f"no `_LLM_BEARING_OPS` assignment found in {EXECUTOR_TASKS}") + + +def test_every_routed_operation_has_a_billing_backstop(): + """The derived assertion the two literal lists cannot make. + + Add an extractor to `EXTRACTOR_ROUTES` and forget the backstop, and this + fails -- without anyone having had to remember a second list. + """ + declared = _llm_bearing_ops_literal() + routed_ops = {operation for _executor, operation in EXTRACTOR_ROUTES.values()} + + missing = routed_ops - declared + assert not missing, ( + f"{sorted(missing)} are routed by EXTRACTOR_ROUTES but absent from " + f"`_LLM_BEARING_OPS` in workers/executor/tasks.py. Every Agent-KV " + f"extractor drives LLM calls, so a run of one that emits zero usage " + f"records means the billing chain broke -- and without the set " + f"membership, nothing logs it. Add the op there (and to " + f"workers/tests/test_llm_bearing_ops.py's PAID_OPERATIONS)." + ) + + +def test_the_backstop_derivation_is_not_vacuous(): + """If the AST parse silently returned nothing, the test above would pass.""" + assert _llm_bearing_ops_literal(), ( + "parsed no operation names out of `_LLM_BEARING_OPS` -- the assignment " + "shape changed and the derivation above is now asserting against an " + "empty set" + ) diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py index 00e6a21b8e..e3c285830c 100644 --- a/backend/agent_kv/tests/test_submit_view.py +++ b/backend/agent_kv/tests/test_submit_view.py @@ -118,7 +118,9 @@ def _mock_serializer(m_cls, **overrides): _ROLE_TYPES = {"llm": "LLM", "lite_llm": "LLM", "x2text": "X2TEXT"} -def _adapters_owned_by(organization_id, *, types=None, missing=()): +def _adapters_owned_by( + organization_id, *, types=None, missing=(), unusable=(), unavailable=() +): """Patch the single-adapter lookup `_resolved_adapters` performs. Patches `ev._lookup_adapter`, the view's own seam, rather than the ORM. @@ -135,18 +137,35 @@ def _adapters_owned_by(organization_id, *, types=None, missing=()): An id in `missing` resolves to None (not this org's, or nonexistent -- the gate cannot and must not tell those apart); everything else resolves to an adapter of the type `types` gives, defaulting to the right one per role. + `unusable` / `unavailable` flip `is_usable` / `is_available` for the ids + named, which is how the trial-exhaustion and deprecation refusals are + reached here. + + Both flags are set EXPLICITLY on every stand-in, never left to `Mock`'s + auto-attribute. A bare `mock.Mock()` returns a truthy child for any + attribute, so a gate that reads `adapter.is_usable` would be satisfied by a + mock that was never asked to model it -- the gate could be deleted and + these tests would still pass. The real values live in + `tests/test_adapter_scoping.py`, against rows the DB defaulted itself. """ type_by_id = { aid: (types or {}).get(role, _ROLE_TYPES[role]) for role, aid in _TABLE_ADAPTERS.items() } missing_ids = {str(m) for m in missing} + unusable_ids = {str(m) for m in unusable} + unavailable_ids = {str(m) for m in unavailable} def _lookup(adapter_id, org_id): aid = str(adapter_id) if aid in missing_ids or org_id != organization_id: return None - return mock.Mock(adapter_type=type_by_id.get(aid, "LLM")) + return mock.Mock( + id=aid, + adapter_type=type_by_id.get(aid, "LLM"), + is_usable=aid not in unusable_ids, + is_available=aid not in unavailable_ids, + ) return mock.patch.object(ev, "_lookup_adapter", side_effect=_lookup) @@ -948,12 +967,17 @@ def test_a_save_failure_after_staging_removes_the_staged_object( # --------------------------------------------------------------------------- # The adapter tenancy gate. # -# `POST /agent-kv/` names platform adapters by id, and the executor resolves -# them through the platform service WITHOUT re-checking whose they are. So the -# submit-time org filter is the only thing standing between a caller and -# another tenant's LLM credential. Same class as the `organization_id` filter -# on every job-scoped view, and higher consequence: that one leaks a job, this -# one spends someone else's money. +# `POST /agent-kv/` names platform adapters by id. The platform service DOES +# re-scope its lookups by organization (`WHERE id=%s and organization_id=%s`, +# against the org of the job's own platform key), so this gate is defence in +# depth rather than the only thing standing between a caller and another +# tenant's credential -- the comment here previously claimed the latter. +# +# It is still load-bearing for what the platform service does not do: produce a +# 400 at submit naming the role (instead of an `SdkError` mid-run, after a slot +# and staging are spent), and refuse an exhausted trial (`is_usable`) or a +# deprecated adapter (`is_available`), neither of which the platform service +# checks before handing the credentials back. # --------------------------------------------------------------------------- @@ -1042,36 +1066,81 @@ def test_an_adapter_of_the_wrong_type_is_refused( @mock.patch.object(ev, "check_key_rate", return_value=True) @mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) @mock.patch.object(AgentKVKey, "objects") -def test_the_refusal_does_not_reveal_whether_the_adapter_exists( +def test_an_exhausted_trial_adapter_is_refused_before_anything_is_billed( + m_keys, m_plugin, m_rate, m_limiter, m_stage, m_save, m_dispatch +): + """`is_usable=False` must 400, not 202. + + A frictionlessly onboarded org runs on operator-funded sample credentials; + billing flips `is_usable` when the free allowance is gone, and the IDE, + workflows and Prompt Studio all refuse from that moment. The platform + service does NOT check the flag -- it hands the credentials back -- so if + this endpoint does not check it either, the submit is accepted and the + OPERATOR pays for the caller's extraction. + """ + key = kv_key() + m_keys.get.return_value = key + m_limiter.check_and_acquire.return_value = True + m_save.side_effect = _stamp_created_at + + with _adapters_owned_by(key.organization_id, unusable=[_TABLE_ADAPTERS["llm"]]): + resp = ev.SubmitView.as_view()( + _real_multipart_post( + [ + { + "name": "table", + "keys": {"target_table": "Rent rolls"}, + "adapters": _TABLE_ADAPTERS, + "options": {}, + } + ], + ) + ) + + assert resp.status_code == 400 + assert "exhausted" in str(resp.data), resp.data + assert not m_dispatch.called, "an exhausted trial must not reach the executor" + assert not m_stage.called, "nor be charged for staging the upload" + + +@mock.patch.object(ev, "dispatch_job") +@mock.patch.object(AgentKVJob, "save", autospec=True) +@mock.patch.object(ev, "stage_input") +@mock.patch.object(ev, "AgentKVConcurrencyLimiter") +@mock.patch.object(ev, "check_key_rate", return_value=True) +@mock.patch.object(ev, "get_plugin", return_value=_plugin_with_gate()) +@mock.patch.object(AgentKVKey, "objects") +def test_a_deprecated_adapter_is_refused_at_submit( m_keys, m_plugin, m_rate, m_limiter, m_stage, m_save, m_dispatch ): - """"Not yours" and "no such adapter" must read identically. + """`is_available=False` means the SDK registry no longer carries it. - Telling them apart turns the endpoint into an oracle for which adapter - UUIDs are real in other organizations. + Left to the executor this raises `InValidAdapterId` deep in the engine and + reaches the caller as a mid-run extraction failure, on a job that already + took a slot and billed for staging. """ key = kv_key() m_keys.get.return_value = key m_limiter.check_and_acquire.return_value = True m_save.side_effect = _stamp_created_at - bodies = [] - # First: the id exists but belongs to someone else. Second: it exists - # nowhere. The stand-in cannot distinguish them either, which is the point. - for missing in ([_TABLE_ADAPTERS["llm"]], [_TABLE_ADAPTERS["llm"]]): - with _adapters_owned_by(key.organization_id, missing=missing): - resp = ev.SubmitView.as_view()( - _real_multipart_post( - [ - { - "name": "table", - "keys": {"target_table": "Rent rolls"}, - "adapters": _TABLE_ADAPTERS, - "options": {}, - } - ], - ) + with _adapters_owned_by( + key.organization_id, unavailable=[_TABLE_ADAPTERS["x2text"]] + ): + resp = ev.SubmitView.as_view()( + _real_multipart_post( + [ + { + "name": "table", + "keys": {"target_table": "Rent rolls"}, + "adapters": _TABLE_ADAPTERS, + "options": {}, + } + ], ) - bodies.append(str(resp.data)) + ) - assert bodies[0] == bodies[1] + assert resp.status_code == 400 + assert "deprecated" in str(resp.data), resp.data + assert not m_dispatch.called + assert not m_stage.called diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py index cfbee37b7e..94001448ef 100644 --- a/backend/agent_kv/tests/test_sweeps.py +++ b/backend/agent_kv/tests/test_sweeps.py @@ -23,6 +23,7 @@ than on ``internal_views``. """ +import logging import os import uuid from datetime import timedelta @@ -106,7 +107,7 @@ def test_sweep_queries_pending_older_than_grace_and_undispatched( resp = iv.SweepView.as_view()(_post("/x")) assert resp.status_code == 200 - assert resp.data == {"swept": 0, "timed_out": 0} + assert resp.data == {"swept": 0, "timed_out": 0, "released": 0} filter_kwargs = m_objects.filter.call_args_list[0].kwargs assert filter_kwargs["status"] == JobStatus.PENDING assert filter_kwargs["dispatched_at__isnull"] is True @@ -176,7 +177,7 @@ def test_sweep_count_reflects_guard_outcomes_not_candidate_count( resp = iv.SweepView.as_view()(_post("/x")) assert resp.status_code == 200 - assert resp.data == {"swept": 1, "timed_out": 0} + assert resp.data == {"swept": 1, "timed_out": 0, "released": 0} m_release.assert_called_once_with("org1", str(won_job.id)) @@ -191,7 +192,7 @@ def test_sweep_with_no_candidates_is_a_pure_noop(m_objects, m_mark_terminal, m_r resp = iv.SweepView.as_view()(_post("/x")) assert resp.status_code == 200 - assert resp.data == {"swept": 0, "timed_out": 0} + assert resp.data == {"swept": 0, "timed_out": 0, "released": 0} assert not m_mark_terminal.called assert not m_release.called @@ -217,7 +218,7 @@ def test_stuck_sweep_queries_dispatched_and_running_older_than_stuck_grace( resp = iv.SweepView.as_view()(_post("/x")) assert resp.status_code == 200 - assert resp.data == {"swept": 0, "timed_out": 0} + assert resp.data == {"swept": 0, "timed_out": 0, "released": 0} filter_kwargs = m_objects.filter.call_args_list[1].kwargs assert set(filter_kwargs["status__in"]) == {JobStatus.DISPATCHED, JobStatus.RUNNING} cutoff = frozen_now - timedelta(seconds=settings.AGENT_KV_STUCK_JOB_GRACE_SECONDS) @@ -259,7 +260,7 @@ def test_stuck_sweep_terminalizes_each_candidate_as_failed_timed_out( resp = iv.SweepView.as_view()(_post("/x")) assert resp.status_code == 200 - assert resp.data == {"swept": 0, "timed_out": 1} + assert resp.data == {"swept": 0, "timed_out": 1, "released": 0} m_mark_terminal.assert_called_once_with( job.id, "org1", JobStatus.FAILED, error="Job timed out" ) @@ -298,7 +299,7 @@ def test_stuck_sweep_count_reflects_guard_outcomes_not_candidate_count( resp = iv.SweepView.as_view()(_post("/x")) assert resp.status_code == 200 - assert resp.data == {"swept": 0, "timed_out": 1} + assert resp.data == {"swept": 0, "timed_out": 1, "released": 0} m_release.assert_called_once_with("org1", str(won_job.id)) @@ -320,7 +321,7 @@ def test_sweep_reports_both_phase_counts_independently( resp = iv.SweepView.as_view()(_post("/x")) assert resp.status_code == 200 - assert resp.data == {"swept": 1, "timed_out": 1} + assert resp.data == {"swept": 1, "timed_out": 1, "released": 0} # --------------------------------------------------------------------------- @@ -699,6 +700,98 @@ def test_sweep_releases_slots_held_by_abandoned_cancelled_jobs( assert not m_mark_terminal.called +@mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +def test_phase_three_s_release_count_reaches_the_caller( + m_objects, m_mark_terminal, m_release +): + """`released` must be in the RESPONSE, not only in a log line. + + Phase 3 counted its releases into a local, logged them, and returned only + `{"swept", "timed_out"}`. `SweepView` passes that dict through verbatim and + the scheduler task logs it, so no caller could ever see that a slot had + been recovered -- the same "counted then discarded" shape as the round-1 + finding about this function's return value, reintroduced by the phase added + to fix another part of it. + + Operationally: a cancelled job whose executor never called back holds a + slot against the org's cap, and phase 3 is the only thing that frees it. + Without this key, "the cap is being hit and the sweep is recovering slots" + and "the cap is being hit and nothing is recovering anything" look the same + from outside. + """ + jobs = [] + for _ in range(3): + job = AgentKVJob(status=JobStatus.CANCELLED) + job.organization_id = "org1" + jobs.append(job) + _wire_sweep_phases(m_objects, cancelled=jobs) + + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.status_code == 200 + assert resp.data["released"] == 3, resp.data + assert m_release.call_count == 3 + + +@mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +def test_a_sweep_that_strands_jobs_says_so_at_warning( + m_objects, m_mark_terminal, m_release, caplog +): + """The logging added for round-1 finding 2, pinned. + + Nothing anywhere used `caplog` on this module, so all five log lines could + be deleted and the suite stayed green -- `test_agent_kv_scheduler_tasks.py` + asserts the returned dict, which passed before the logging existed too. + + WARNING specifically, not INFO: a non-zero count means jobs were stranded + and their concurrency slots held, which is the condition an operator needs + surfaced. Before this, a sweep terminalizing a thousand jobs as FAILED + emitted nothing at all, so a backlog of stranded jobs was indistinguishable + from a quiet, healthy system. + """ + job = AgentKVJob(id=uuid.uuid4(), organization_id="org1") + _wire_sweep_phases(m_objects, never_dispatched=[job]) + + with caplog.at_level(logging.INFO, logger="agent_kv.maintenance"): + resp = iv.SweepView.as_view()(_post("/x")) + + assert resp.data["swept"] == 1 + terminalized = [ + r + for r in caplog.records + if r.levelno >= logging.WARNING and "terminalized" in r.getMessage() + ] + assert len(terminalized) == 1, [r.getMessage() for r in caplog.records] + assert "1 never-dispatched" in terminalized[0].getMessage() + + +@mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +def test_a_quiet_sweep_still_records_that_it_RAN( + m_objects, m_mark_terminal, m_release, caplog +): + """The other half, and the one that matters more. + + "Ran and found nothing" and "never ran at all" produce the same counts, and + distinguishing them is the whole reason this line exists -- the sweep + shipped unscheduled and the only symptom was an absence. So the quiet path + must log too, at INFO. + """ + _wire_sweep_phases(m_objects) + + with caplog.at_level(logging.INFO, logger="agent_kv.maintenance"): + iv.SweepView.as_view()(_post("/x")) + + assert any( + "nothing to terminalize" in r.getMessage() for r in caplog.records + ), [r.getMessage() for r in caplog.records] + + @mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "objects") def test_phase_three_is_bounded_on_both_sides(m_objects, m_release): diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index a9592466fa..316cd85a1b 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -299,6 +299,15 @@ Not exposed (D6): model choice, challenger model, `parallel_pages`, thinking bud `pdfplumber` and rejected over `AGENT_KV_MAX_PAGES` before any paid work runs. Excel has no pre-OCR page concept — it is capped by file size only at submit time (the engine enforces a post-OCR virtual-page cap). + - **The cap bounds the pages this request will PROCESS, not the document's + length.** It is checked against `pages_selected` — the count after + `page_start` / `page_end` are applied — not against `pages_total`. So pages + 1–50 of a 400-page PDF is accepted under a 100-page cap, while the same + document submitted without a page range is refused. That is the intended + reading: the cap exists to bound paid work per job, and a page range is the + caller declaring how much work they are asking for. `pages_total` is still + recorded on the job and returned in the status document, so the document's + real length is never lost. - Images (`.png .jpg .jpeg .tiff`) are **not** accepted, and no longer count as 1 page. The engine treats only `.pdf/.xlsx/.xls` as a document and `ImageLoader.load_pages` has no call site, so an accepted image returned @@ -423,6 +432,11 @@ list for the extractor that actually ran ] } }, + "adapters": { + "llm": "3f2b1c90-...", + "lite_llm": "a71e4d55-...", + "x2text": "c90aa812-..." + }, "created_at": "2026-08-28T10:15:00.123456+00:00", "started_at": "2026-08-28T10:15:01.500000+00:00", "completed_at": null, @@ -436,9 +450,17 @@ Each stage entry always carries `status` (`running`|`done`) and, if reported, counters, and any counter using the reserved keys `status`/`seconds`, are dropped by the internal stage-report endpoint rather than persisted (`backend/agent_kv/internal_views.py::_sanitize_counters`). +`adapters` echoes the platform adapter ids this job was dispatched with, by role +— the ones the submit named and the gate validated (§3a). It is reported because +the caller chose them and is paying for them: naming the wrong `llm` id yields a +correct-looking result at a different price, and the only other record of what +was spent is in `usage_v2`, which the caller cannot read. **Ids only, never +adapter metadata** — that is where provider credentials live. `{}` for an +env-configured extractor (`kv`) and for any job predating the field. + `status` is lowercased (`pending`, `dispatched`, `running`, `completed`, `failed`, -`cancelled`); `error` is included (only) when `status == "failed"`. `pages_total` and -`completed_at` are always present (as `null` until known) — an addition beyond the +`cancelled`); `error` is included (only) when `status == "failed"`. `adapters`, +`pages_total` and `completed_at` are always present (as `null`/`{}` until known) — an addition beyond the spec's illustrative example, not a drift, since spec §7.2 was explicitly a partial sample ("Verbose, agent-centric, stage-level"). @@ -846,10 +868,28 @@ beyond `docker compose up`: (`backend/agent_kv/management/commands/`) — each printing the JSON counts (`{"swept": N, "timed_out": M}` / `{"cleaned": N}`) and exiting 0. In the cloud deployment, a Kubernetes CronJob runs each command on the cadences above instead - of registering a `PgPeriodicTask` row. The scheduler proxy tasks - (`agent_kv.sweep`/`agent_kv.ttl_cleanup`) and their PG-scheduler registration - remain the mechanism for OSS/self-hosted deployments, which have no CronJob - equivalent to drive from. + of registering a `PgPeriodicTask` row. + + **The CronJob is the only owner.** Migration `0004_pg_periodic_tasks` once also + seeded two `PgPeriodicTask` rows for the same two tasks; it now removes them. + They were seeded `pg_owned: False` so nothing double-fired, but the flag exists + to be flipped, and a flip would have run a `*/10` sweep against the chart's + `*/15` — colliding at `:30` on the same rows — with TTL cadences disagreeing 24x. + Only the CronJob side carries `concurrencyPolicy: Forbid` and a deadline. + + > **Self-hosted OSS: you must schedule these two yourself.** There is no CronJob + > outside the cloud chart and no migration registers them any more. Run + > `manage.py agent_kv_sweep` and `manage.py agent_kv_ttl_cleanup` from cron, a + > systemd timer, or your own `PgPeriodicTask` rows with `pg_owned: True` — the + > scheduler proxy tasks `agent_kv.sweep`/`agent_kv.ttl_cleanup` are still + > registered and still work. Without them a stranded job holds its concurrency + > slot until the 6 h Redis TTL, and `AGENT_KV_RESULT_TTL_DAYS` is advisory: + > staged documents and results stay in the bucket indefinitely, which is a + > retention failure, not just a disk-usage one. + > + > Note this deployment routes `table` only, and that extractor needs the cloud + > `agentic_table` plugin — so a pure-OSS install cannot dispatch an Agent-KV job + > at all, and has no stranded rows to reap until it ships an engine plugin. **Chart keys (cloud repo)**: `backend.agentKvCronJobs` in `charts/unstract-platform/values.yaml` — `enabled: false` there (on-prem never runs @@ -865,13 +905,27 @@ beyond `docker compose up`: 5. **Env vars**: every `AGENT_KV_*` setting plus `AGENT_KV_FILE_STORAGE_CREDENTIALS` — see [§6](#12-environment-reference) and `docker/sample.env`. - > **For the `table` extractor, the executor-side credential group below is NOT - > required.** That path resolves the caller's own platform adapters (see §3a), so - > none of the six `AGENT_KV_LLM_*` / `AGENT_KV_LLMWHISPERER_*` values is read on - > it. The group only has to be configured where the `kv` extractor is deployed, - > which is a separate release track. A table-only deployment can leave + > **For the `table` extractor, the executor-side credential group below must be + > left UNSET — not merely "is not required".** That path resolves the caller's + > own platform adapters (see §3a), so none of the six `AGENT_KV_LLM_*` / + > `AGENT_KV_LLMWHISPERER_*` values is read on it. The group only has to be + > configured where the `kv` extractor is deployed, which is a separate release + > track. A table-only deployment leaves > `global.sharedConfigs.agentKv.enabled: false` and the API works. > + > **Deploy order: cloud worker first, then the backend.** The two repos deploy + > independently and nothing on the wire marks the capability. A new backend + > against an *old* worker sends validated `adapters` in `executor_params` to a + > worker that has no `ParamKeys.ADAPTERS`; it ignores the key and calls + > `ExtractionConfig.from_env()`. If those six values are set it runs on + > **operator** credentials, bills the `agent_kv:{provider}/{model}` sentinel, and + > returns a 202 with a correct-looking result — while the adapters the caller + > named were never used. Nothing in either repo's logs reveals it. With the group + > unset the same old worker fails loudly with "Missing required Agent-KV env + > vars", which is what a half-finished rollout should look like. The reverse + > order is safe by construction: the new worker hard-fails "Missing required + > adapters" rather than falling back to env. + > > Two operator knobs are still read on the table path, independently of that > group: `AGENT_KV_STORAGE_DIR_PREFIX` (which must match the backend's — the > executor derives the job's expected directory from it to enforce the tenant @@ -1067,7 +1121,7 @@ with the default shown: |---|---|---| | `AGENT_KV_PATH_PREFIX` | `agent-kv` | Top-level public URL prefix (whitelisted past tenant middleware). | | `AGENT_KV_MAX_FILE_SIZE_MB` | `50` | Submit-time file size cap. | -| `AGENT_KV_MAX_PAGES` | `100` | Pre-OCR page cap for PDFs (§6.1). Excel has no pre-OCR page concept; images are not accepted. | +| `AGENT_KV_MAX_PAGES` | `100` | Pre-OCR cap on the pages a request will PROCESS for PDFs — checked against `pages_selected` (after `page_start`/`page_end`), not the document's `pages_total` (§6.1). Excel has no pre-OCR page concept, so it takes the engine's post-OCR virtual-page cap instead; images are not accepted. | | `AGENT_KV_MAX_CALCULATIONS_BYTES` | `20000` | Byte cap on the optional `calculations` field. | | `AGENT_KV_MAX_SCHEMA_BYTES` | `262144` | Byte cap on the raw `keys` JSON document (256 KiB). | | `AGENT_KV_RESULT_TTL_DAYS` | `7` | Result retention window (and a cancelled job's input, which rides the same TTL — a completed/failed job's input is deleted immediately at finalize instead), stamped at submit time (see [§6](#9-retention-and-ttl)). | diff --git a/tests/e2e/agent_kv/conftest.py b/tests/e2e/agent_kv/conftest.py index 673787e07f..392ccba914 100644 --- a/tests/e2e/agent_kv/conftest.py +++ b/tests/e2e/agent_kv/conftest.py @@ -227,6 +227,15 @@ def submit_raw( # OCR adapters the extraction runs on. Defaults to whatever the # environment configured; `{}` is sent as-is so a scenario can assert # the 400 for an absent block. + # + # When the env vars are UNSET this omits the block, and the backend + # then refuses the submit at the adapter field validator -- before any + # file or page-cap check. That is correct behaviour but it is a trap + # for tests asserting a DIFFERENT 400: they still see a 400, then fail + # on the blamed attribute (`extractors`, not `file`). Red, not skipped. + # Every scenario that asserts a specific rejection attribute therefore + # takes `require_adapters`, which turns the unconfigured lane into an + # explicit skip naming the missing vars. resolved = e2e_adapters() if adapters is None else adapters if resolved: entry["adapters"] = resolved diff --git a/tests/e2e/agent_kv/test_agent_kv_e2e.py b/tests/e2e/agent_kv/test_agent_kv_e2e.py index c515ca5dd0..8790f99843 100644 --- a/tests/e2e/agent_kv/test_agent_kv_e2e.py +++ b/tests/e2e/agent_kv/test_agent_kv_e2e.py @@ -19,13 +19,23 @@ expect a rejection BEFORE that gate (a bad key, the rate limiter, an absent ``extractors``) do not take it. -A third, narrower gate (``require_llm``) applies only to the scenarios whose -assertions depend on a job actually reaching COMPLETED: either -``AGENT_KV_LLM_API_KEY`` or ``UNSTRACT_LLM_MOCK_RESPONSE`` must be set. Note -that on the table path the LLM credential now lives on the ADAPTER rather -than in the executor's environment, so a real-adapter run satisfies this -whether or not that variable is exported -- it is kept for the mock case and -for the KV path. +There is no third gate for "can a job actually reach COMPLETED". There used to +be (``require_llm``, on ``AGENT_KV_LLM_API_KEY`` or +``UNSTRACT_LLM_MOCK_RESPONSE``), and on the table path it was wrong: the LLM +credential lives on the ADAPTER now, not in the executor's environment, so a +correctly configured lane with adapters and no exported key SKIPPED the only +two scenarios that poll a job through to a genuine COMPLETED result -- the most +valuable assertions in the file. ``require_adapters`` is the whole gate: +configuring adapter ids is what declares this lane can run real extraction, and +(unless the adapters point at a mock) that it will spend real provider money. + +One scenario needs the opposite -- a job that dispatches and then FAILS inside +the engine -- and takes ``bad_llm_adapter``, the id of an adapter deliberately +carrying an invalid credential. That also moved: it was gated on +``AGENT_KV_E2E_BAD_KEY_JOB=1`` plus an invalid ``AGENT_KV_LLM_API_KEY``, a +variable this path does not read, so an operator who set the stack up exactly +as documented got a job that ran on the valid adapter and reached +``completed``. Values extracted under ``UNSTRACT_LLM_MOCK_RESPONSE`` are whatever the mock config returns, not real answers -- assertions here deliberately check @@ -73,6 +83,7 @@ AgentKVAuth, cancel, delete, + e2e_adapters, missing_adapter_env, poll, result, @@ -202,20 +213,29 @@ def require_adapters() -> None: @pytest.fixture -def require_llm() -> None: - """Skip a scenario that needs a job to actually reach COMPLETED. - - Every other scenario in this module resolves before any LLM call - (auth/schema/limiter/page-cap checks); only the two that poll a job all - the way to a genuine COMPLETED result need this. +def bad_llm_adapter() -> str: + """An adapter id whose provider credential is deliberately INVALID. + + Replaces the old `AGENT_KV_E2E_BAD_KEY_JOB=1` + invalid + `AGENT_KV_LLM_API_KEY` arrangement, which stopped working when the table + path moved onto caller-supplied adapters: that env var is not read on this + path at all, so the scenario it gated could no longer be produced. A test + that declared a precondition the stack could not satisfy -- and then ran + against a VALID credential, reaching `completed` and failing its own + `status == "failed"` assertion. + + The credential now lives on an adapter row, so the trigger has to as well. + Create one LLM adapter with a junk API key in the target org and export its + id; nothing else about the lane changes. """ - has_key = os.environ.get("AGENT_KV_LLM_API_KEY") - has_mock = os.environ.get("UNSTRACT_LLM_MOCK_RESPONSE") - if not (has_key or has_mock): + adapter_id = os.environ.get("AGENT_KV_E2E_BAD_LLM_ADAPTER") + if not adapter_id: pytest.skip( - "needs AGENT_KV_LLM_API_KEY or UNSTRACT_LLM_MOCK_RESPONSE so the " - "job can actually reach COMPLETED" + "needs AGENT_KV_E2E_BAD_LLM_ADAPTER: the id of an LLM adapter in " + "this key's organization carrying a deliberately INVALID provider " + "credential, so a job dispatches and then fails inside the engine" ) + return adapter_id # --------------------------------------------------------------------------- @@ -239,7 +259,7 @@ def test_submit_without_key_is_403(agent_kv_key: AgentKVAuth) -> None: def test_table_extractor_happy_path( - require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None + require_adapters: None, agent_kv_key: AgentKVAuth ) -> None: """A `table` entry runs the table engine and files its result under `table`. @@ -332,9 +352,21 @@ def test_an_unknown_extractor_is_400(agent_kv_key: AgentKVAuth) -> None: # --------------------------------------------------------------------------- -def test_submit_unreadable_pdf_is_400(agent_kv_key: AgentKVAuth) -> None: +def test_submit_unreadable_pdf_is_400( + require_adapters: None, agent_kv_key: AgentKVAuth +) -> None: """A ``.pdf``-named file that isn't a real PDF is rejected before dispatch. + Takes ``require_adapters`` even though it asserts a REJECTION. The claim + that "scenarios expecting a rejection before the adapter gate keep working + unchanged" holds only for the three raised inside ``validate_extractors``; + this rejection comes from ``SubmitSerializer.validate()``, and DRF raises + field-level errors from ``to_internal_value`` BEFORE ``validate()`` runs -- + with ``_validated_adapter_shape`` called from the field validator. So with + the adapter vars unset (the documented default) the status assert still + passed on a 400 and the attr assert then FAILED, blaming ``extractors`` + instead of ``file``. Red, not skipped. + This is the submit-serializer's own page-count check (``pdfplumber.open()`` raising -> ``{"file": "Unreadable PDF"}``), which is deterministic and needs no LLM/engine work to reach. The executor's @@ -439,7 +471,7 @@ def test_cancelled_job_does_not_leak_its_concurrency_slot( def test_delete_completed_job_then_result_404( - require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None + require_adapters: None, agent_kv_key: AgentKVAuth ) -> None: job_id, _ = submit( agent_kv_key, @@ -462,7 +494,25 @@ def test_delete_completed_job_then_result_404( # --------------------------------------------------------------------------- -def test_page_cap_rejects_oversized_document(agent_kv_key: AgentKVAuth) -> None: +def test_page_cap_rejects_oversized_document( + require_adapters: None, agent_kv_key: AgentKVAuth +) -> None: + """The page cap refuses an oversized document at submit. + + Takes ``require_adapters`` even though it asserts a REJECTION. The claim + that "scenarios expecting a rejection before the adapter gate keep working + unchanged" holds only for the three raised inside ``validate_extractors``; + this rejection comes from ``SubmitSerializer.validate()``, and DRF raises + field-level errors from ``to_internal_value`` BEFORE ``validate()`` runs -- + with ``_validated_adapter_shape`` called from the field validator. So with + the adapter vars unset (the documented default) the status assert still + passed on a 400 and the attr assert then FAILED, blaming ``extractors`` + instead of ``file``. Red, not skipped. + + Same defect as ``test_submit_unreadable_pdf_is_400``, but masked: the + ``AGENT_KV_MAX_PAGES`` skip below usually fires first, so it would only + have gone red on a lane that configured the cap. + """ fixture_pages = 2 # fixtures/invoice.pdf cap_raw = os.environ.get("AGENT_KV_MAX_PAGES") if cap_raw is None or int(cap_raw) > fixture_pages: @@ -493,8 +543,8 @@ def _assert_user_safe_error(error: str) -> None: assert leak not in error, f"a filesystem path leaked into: {error!r}" -def test_bad_llm_key_ends_failed( - require_adapters: None, agent_kv_key: AgentKVAuth +def test_a_bad_adapter_credential_ends_failed( + require_adapters: None, agent_kv_key: AgentKVAuth, bad_llm_adapter: str ) -> None: """A job that DISPATCHES and then fails in the executor ends ``failed`` with a user-safe error. @@ -502,9 +552,16 @@ def test_bad_llm_key_ends_failed( Every other failure scenario in this module is a request-time rejection (400/403/429) that never reaches the engine. This one covers the other half of the contract -- the terminal FAILED path -- and it needs the whole - stack to be running with a deliberately invalid ``AGENT_KV_LLM_API_KEY``, - which no test can arrange for itself. Hence the explicit operator gate - (see the module docstring for how to run it). + stack to be running with an adapter whose provider credential is + deliberately invalid, which no test can mint for itself. Hence the explicit + operator gate (see ``bad_llm_adapter``). + + It used to gate on ``AGENT_KV_E2E_BAD_KEY_JOB=1`` plus an invalid + ``AGENT_KV_LLM_API_KEY``. That env var is not read on the table path any + more, so an operator who set the stack up exactly as documented got a job + that ran on the VALID adapter credential, reached ``completed``, and failed + this test's own assertion. The trigger moved to where the credential now + lives. The assertion is about SHAPE, not text: whatever went wrong inside the engine, the customer-visible ``error`` must not carry a filesystem path, a @@ -513,18 +570,16 @@ def test_bad_llm_key_ends_failed( ``cancelled`` / ``timed out``), which is what the executor now builds from the node listener's exception rather than echoing the engine's own text. """ - if os.environ.get("AGENT_KV_E2E_BAD_KEY_JOB") != "1": - pytest.skip( - "needs a platform deliberately configured with an INVALID " - "AGENT_KV_LLM_API_KEY; set AGENT_KV_E2E_BAD_KEY_JOB=1 to declare " - "that this stack is running that way (see the module docstring)" - ) + # The advanced-LLM role carries the junk credential; the other two stay + # valid, so the run gets past OCR and fails where this test says it does. + adapters = {**e2e_adapters(), "llm": bad_llm_adapter} job_id, _ = submit( agent_kv_key, _RENT_ROLL_BYTES, "rent_roll.pdf", table_keys(), + adapters=adapters, ) status_doc = poll(agent_kv_key, job_id, timeout_s=600) @@ -551,7 +606,7 @@ def test_bad_llm_key_ends_failed( def test_sync_wait_submit_returns_result_inline( - require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None + require_adapters: None, agent_kv_key: AgentKVAuth ) -> None: """A submit with ``timeout`` seconds sync-waits: if the job reaches a terminal state inside the window, the response is 200 with the result @@ -585,7 +640,7 @@ def test_sync_wait_submit_returns_result_inline( def test_excel_submit_extracts( - require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None + require_adapters: None, agent_kv_key: AgentKVAuth ) -> None: """An .xlsx submit runs the whole pipeline. Excel has no pre-OCR page count (``pages_total`` stays None at submit) and takes the post-OCR @@ -609,7 +664,7 @@ def test_excel_submit_extracts( def test_webhook_delivered_on_completion( - require_adapters: None, agent_kv_key: AgentKVAuth, require_llm: None + require_adapters: None, agent_kv_key: AgentKVAuth ) -> None: """Completion-webhook delivery, end to end: submit with a ``webhook_url`` pointing at a receiver on the compose host and wait for the POST. diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py index a7ecc37611..b3aa72e075 100644 --- a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py @@ -220,13 +220,20 @@ def _reject_unsafe_regex(path: str, pattern: str) -> None: def _reject_unusable_formats(key_specs, array_specs) -> None: """Refuse declared formats that can never validate anything, or always fail. - Three shapes, all accepted before review and all verified by execution: + Four shapes, all accepted before review and all verified by execution: * ``format: "Number"`` -- a case variant of a known format. Silently degrades to a free-text hint, disabling validation for that key. Rejected with the intended spelling, rather than guessed at: a schema author who meant the hint can lower-case it or reword it, and one who meant the format gets told. + * ``format: "Enum:paid,unpaid"`` / ``"REGEX:^[0-9]+$"`` -- the same defect + class for the two formats that take an ARGUMENT, and the one the first + round's fix missed. ``_parse_format`` matches the ``enum:`` / ``regex:`` + prefix case-sensitively, so these never reach kind ``"enum"``/``"regex"``; + they arrive as the whole raw string, which no bare-name comparison + matches. Validation was silently off for the key -- the precise outcome + the first bullet exists to prevent, reached by a route it did not cover. * ``format: "enum:"`` -- no values, so ``_check_one`` tests membership of the empty set and EVERY non-empty value fails QA for the life of the job. * ``format: "regex:"`` -- empty pattern, so ``re.fullmatch("", v)`` matches @@ -240,7 +247,28 @@ def _reject_unusable_formats(key_specs, array_specs) -> None: for kspec in _every_leaf(key_specs, array_specs): fmt = kspec.format lowered = fmt.casefold() - if fmt not in _KNOWN_FORMATS and lowered in _KNOWN_FORMATS: + # `lowered` catches a bare case variant (`"Number"`). `kind_only` + # catches the two formats that take an ARGUMENT, which the bare check + # misses entirely: `_parse_format` matches the `enum:` / `regex:` + # prefix case-SENSITIVELY, so `"Enum:paid,unpaid"` never becomes kind + # `"enum"` -- it arrives here as the whole raw string, and + # `"enum:paid,unpaid".casefold()` is not a member of `_KNOWN_FORMATS` + # (which holds the bare `"enum"`). So the mis-cased form slipped this + # guard, parsed as a free-text kind, and validation was silently off + # for that key -- exactly the failure this function closes for bare + # formats. + # + # Splitting on the colon is safe for genuine free text: a hint like + # `"customer:id"` yields `"customer"`, which is not a known format. + # + # Rejected rather than auto-corrected, to match the bare case above: a + # schema author who meant the format gets told the spelling, and one + # who meant a free-text hint can reword it. Silently honouring `Enum:` + # would make case significant in one direction only. + kind_only = lowered.split(":", 1)[0] + if fmt not in _KNOWN_FORMATS and ( + lowered in _KNOWN_FORMATS or kind_only in _KNOWN_FORMATS + ): raise SchemaError( f"'{kspec.path}' declares format {fmt!r}, which is not a known " f"format and is therefore treated as free text -- no validation " diff --git a/unstract/agent-kv-schema/tests/test_compile.py b/unstract/agent-kv-schema/tests/test_compile.py index c21e61fc71..471df824c7 100644 --- a/unstract/agent-kv-schema/tests/test_compile.py +++ b/unstract/agent-kv-schema/tests/test_compile.py @@ -289,6 +289,72 @@ def test_a_case_variant_of_a_known_format_is_refused(fmt, intended): compile_schema(spec) +@pytest.mark.parametrize( + ("fmt", "intended"), + [ + ("Enum:paid,unpaid", "enum:paid,unpaid"), + ("ENUM:paid,unpaid", "enum:paid,unpaid"), + ("Regex:^[0-9]+$", "regex:^[0-9]+$"), + ("REGEX:^[0-9]+$", "regex:^[0-9]+$"), + ], +) +def test_a_case_variant_of_an_ARGUMENT_taking_format_is_refused(fmt, intended): + """The residual the first round's fix left behind. + + `test_a_case_variant_of_a_known_format_is_refused` above covers the BARE + names, including bare `"Enum"` and `"Regex"` -- but not the forms that + actually carry an argument, which is how anyone real writes them. + `_parse_format` matches the `enum:` / `regex:` prefix case-sensitively, so + `"Enum:paid,unpaid"` never becomes kind `"enum"`; it reached the typo guard + as the whole raw string, and `"enum:paid,unpaid"` is not a member of + `_KNOWN_FORMATS` (which holds the bare `"enum"`). + + Net effect before this: the key compiled as a free-text hint, `/validate` + reported `{"valid": true}`, and the enum was never enforced -- identical to + the `"Number"` failure, through the one gap the fix for it did not close. + """ + with pytest.raises(SchemaError, match="Did you mean"): + compile_schema(_one_key(fmt)) + + # And the suggestion has to be the usable spelling, argument included -- + # `"enum"` alone would be rejected by the no-values check one line later, + # sending the author in a circle. + with pytest.raises(SchemaError) as caught: + compile_schema(_one_key(fmt)) + assert intended in str(caught.value) + + +@pytest.mark.parametrize( + "fmt", + ["enum:paid,unpaid", "regex:^[0-9]+$", "enum:a,b,c"], +) +def test_correctly_cased_argument_formats_still_compile(fmt): + """The other half: the fix must not reject the valid spellings. + + These reach the guard with kind already resolved to `"enum"`/`"regex"` by + `_parse_format`, so the colon-splitting branch must not fire on them. + """ + compile_schema(_one_key(fmt)) + + +@pytest.mark.parametrize( + "fmt", + [ + "customer:id", # a colon in genuine free text + "total:in USD", + "ref:12345", + ], +) +def test_free_text_containing_a_colon_is_not_mistaken_for_a_format(fmt): + """Splitting on `:` must not capture unrelated hints. + + The prefix before the colon is not a known format in any of these, so they + stay free text -- which is what a schema author writing a descriptive hint + expects. + """ + compile_schema(_one_key(fmt)) + + @pytest.mark.parametrize("fmt", ["number", "date", "currency", "string"]) def test_the_known_formats_still_compile(fmt): compile_schema(_one_key(fmt)) diff --git a/unstract/sdk1/tests/test_page_usage_metering.py b/unstract/sdk1/tests/test_page_usage_metering.py new file mode 100644 index 0000000000..6d242e4764 --- /dev/null +++ b/unstract/sdk1/tests/test_page_usage_metering.py @@ -0,0 +1,172 @@ +"""`push_usage_details` is the single point where a page-usage row is billed. + +Pinned because two independent paths depend on the counts it produces and one +of them disagrees with it today (UN-4232): + +* PDFs are counted by `pdfplumber`, one row per real page. +* **Everything else is counted as ONE page**, with a standing + `TODO: Calculate page usage for other file types` at the call site. + +The second is a real under-count for spreadsheets. The agentic table engine +splits an Excel workbook into *virtual* pages and extracts each one, so a +12-sheet workbook does twelve pages of work, reports twelve in the job result, +and bills one. That affects both the IDE table path +(`agentic_table/src/executor.py`) and the Agent-KV blind API +(`agentic_table/src/api_binding.py`) identically -- they share this function -- +so it is a product-wide billing question, not a per-path bug, and correcting it +here moves both at once. + +These tests do not assert that the current numbers are RIGHT. They assert what +they currently are, so that: + +1. A change to the non-PDF count surfaces as a failing test with this + explanation attached, instead of silently re-pricing every spreadsheet + extraction in the product. +2. `file_name` and `run_id` keep flowing from `usage_kwargs` onto the row -- + the two fields a billing dispute is reconstructed from. The blind API + shipped without `file_name` once already. + +Excel page metering has moved twice on this branch (an over-count from blank +chunks, then this under-count), which is why it is pinned rather than +described. +""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any +from unittest import mock + +import pytest +from unstract.sdk1.constants import MimeType +from unstract.sdk1.x2txt import X2Text + + +def _x2text(usage_kwargs: dict[str, str]) -> X2Text: + """An `X2Text` with its `__init__` bypassed. + + Constructing one for real needs a platform round-trip to resolve an adapter; + `push_usage_details` touches only `self._tool` and `self._usage_kwargs`. + """ + inst = object.__new__(X2Text) + inst._usage_kwargs = usage_kwargs + inst._tool = mock.Mock(get_env_or_die=mock.Mock(return_value="pk")) + return inst + + +def _minimal_pdf(pages: int) -> bytes: + """A structurally valid PDF with `pages` empty pages. + + Built by hand rather than with a fixture file so the page count is visible + in the test that asserts on it. The PDF branch really does open the file + with `pdfplumber`, so a placeholder byte string is not enough. + """ + objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [{}] /Count {} >>".format( + " ".join(f"{3 + i} 0 R" for i in range(pages)), pages + ), + *[ + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>" + for _ in range(pages) + ], + ] + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for n, body in enumerate(objs, start=1): + offsets.append(len(out)) + out += f"{n} 0 obj\n{body}\nendobj\n".encode() + xref_at = len(out) + out += f"xref\n0 {len(objs) + 1}\n0000000000 65535 f \n".encode() + for off in offsets: + out += f"{off:010d} 00000 n \n".encode() + out += ( + f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n" + f"startxref\n{xref_at}\n%%EOF\n" + ).encode() + return bytes(out) + + +XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + + +@pytest.fixture +def pushed(tmp_path: Path) -> Callable[..., dict[str, Any]]: + """Capture the single `push_page_usage_data` call as a kwargs dict.""" + + def _run( + mime_type: str, + usage_kwargs: dict[str, str], + *, + pdf_pages: int = 1, + name: str = "book.xlsx", + ) -> dict[str, Any]: + f = tmp_path / name + if mime_type == MimeType.PDF: + f.write_bytes(_minimal_pdf(pdf_pages)) + else: + f.write_bytes(b"a real file with a real size, but not a workbook") + with mock.patch("unstract.sdk1.x2txt.Audit") as m_audit: + _x2text(usage_kwargs).push_usage_details(str(f), mime_type) + assert m_audit.return_value.push_page_usage_data.call_count == 1 + return m_audit.return_value.push_page_usage_data.call_args.kwargs + + return _run + + +def test_a_pdf_is_billed_per_real_page(pushed: Callable[..., dict[str, Any]]) -> None: + """The contrast that makes the spreadsheet case a gap rather than a policy. + + PDFs are counted by `pdfplumber`, so pages of work and pages billed agree. + """ + call = pushed(MimeType.PDF, {"run_id": "job-1"}, pdf_pages=3, name="rent-roll.pdf") + + assert call["page_count"] == 3 + + +def test_a_spreadsheet_is_billed_as_a_single_page( + pushed: Callable[..., dict[str, Any]], +) -> None: + """The under-count, pinned with its consequence spelled out. + + If this starts failing because the count became sheet- or virtual-page + aware, that is an INTENTIONAL billing change: update this test, and check + that `agentic_table`'s reported `pages` now agrees with it (the job result + and the usage row currently disagree for Excel). + """ + call = pushed(XLSX_MIME, {"run_id": "job-1", "file_name": "book.xlsx"}) + + assert call["page_count"] == 1, ( + "non-PDF inputs are metered as one page regardless of how many pages " + "of work they cause; the table engine splits a workbook into virtual " + "pages and bills this single row for all of them" + ) + + +def test_the_billing_identifiers_reach_the_row( + pushed: Callable[..., dict[str, Any]], +) -> None: + """`run_id` and `file_name` are carried only by `usage_kwargs`. + + `Audit.push_page_usage_data` reads both off that dict and defaults each to + `""`, so a caller that omits one gets a billed row with no way back to the + job or the input. The Agent-KV blind API omitted `file_name` on its first + cut (UN-4232); nothing failed, which is why this exists. + """ + call = pushed(XLSX_MIME, {"run_id": "job-7", "file_name": "rent-roll.xlsx"}) + + assert call["kwargs"]["run_id"] == "job-7" + assert call["kwargs"]["file_name"] == "rent-roll.xlsx" + + +def test_a_missing_file_name_is_not_rejected_here( + pushed: Callable[..., dict[str, Any]], +) -> None: + """Documents WHY the omission was silent: this layer tolerates it. + + Not an endorsement -- the point is that no exception, no warning and no log + marks the gap, so the only defence is the caller passing the field. Both + table paths now do. + """ + call = pushed(XLSX_MIME, {"run_id": "job-7"}) + + assert call["kwargs"].get("file_name") is None diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index 5b56d3d393..65255ce61c 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -166,12 +166,29 @@ def agent_kv_error( terminalized it with "Job timed out", overwriting the real executor error, which existed only in the log line above. - Now retries with backoff and RAISES on exhaustion, matching its sibling + Now retries and RAISES on exhaustion, matching its sibling ``agent_kv_complete`` and the ``process_batch_callback_api`` precedent in ``workers/callback/tasks.py``. The PG consumer relies on task-level autoretry for exactly this (see its own comment in ``queue_backend/pg_queue/consumer.py``). + **What ``retry_backoff`` actually does here: nothing.** The PG consumer runs + tasks via ``task.apply(..., throw=True)`` + (``queue_backend/pg_queue/consumer.py``) -- i.e. EAGER. Celery's eager + retry recurses synchronously and ignores ``countdown``, so + ``retry_backoff=True`` / ``retry_backoff_max=300`` / ``retry_jitter=True`` + produce three back-to-back attempts in microseconds, which rides out no + "momentary blip" at all. They are kept because they are correct for the + Celery transport and cost nothing under the PG one -- not because they + space the attempts out here. + + The delay that genuinely helps comes AFTER exhaustion, from vt-redelivery + once this raises and the message's visibility timeout lapses. So the value + of the raise is not just the failed-task record: it is the only thing that + buys any real elapsed time before the next attempt. An earlier version of + this docstring credited the decorator with that, which would mislead anyone + tuning the backoff to fix a slow finalize. + ``autoretry_for=(Exception,)`` rather than a narrow transport type: the whole point is that this task must not quietly give up, and a programming error that burns three retries and then surfaces loudly is a far better diff --git a/workers/ide_callback/tasks.py b/workers/ide_callback/tasks.py index 968879bac6..7f2ed70e7c 100644 --- a/workers/ide_callback/tasks.py +++ b/workers/ide_callback/tasks.py @@ -115,7 +115,24 @@ def _get_task_error( if res.result: return str(res.result) except Exception: - pass + # Was a bare `pass`, the same defect fixed in the Agent-KV twin + # (`agent_kv_tasks.py::_error_link`) -- carried over here because the + # cause and the cost are identical, not because this PR owns the IDE + # path. + # + # The common case is a kombu deserialization failure: the executor + # raised an exception class that is not importable in this image, so + # the result backend HAS the error and this process cannot read it. The + # execution is then finalized with the generic `default` -- the exact + # useless message this lookup exists to avoid -- and silently, leaving + # the real cause unrecoverable even from logs. + logger.warning( + "Could not read the executor error from the result backend for " + "task %s; finalizing with the generic message %r", + failed_task_id, + default, + exc_info=True, + ) return default diff --git a/workers/ide_callback/tests/test_agent_kv_callbacks.py b/workers/ide_callback/tests/test_agent_kv_callbacks.py index 6278b48d82..d78f7c670e 100644 --- a/workers/ide_callback/tests/test_agent_kv_callbacks.py +++ b/workers/ide_callback/tests/test_agent_kv_callbacks.py @@ -6,6 +6,7 @@ mocked at the ``ide_callback.agent_kv_tasks`` import site. """ +import logging from unittest.mock import MagicMock, patch import pytest @@ -227,6 +228,53 @@ def test_error_link_falls_back_when_lookup_raises( "job-1", "org-1", success=False, error=_UNKNOWN ) + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_a_swallowed_lookup_failure_is_logged_with_its_traceback( + self, mock_get_client, mock_send_webhook, mock_api, cb_kwargs, caplog + ): + """The handler was a bare `except Exception: pass`. + + `test_error_link_falls_back_when_lookup_raises` above asserts only the + `_UNKNOWN` fallback -- which the bare `pass` also produced, so that test + is byte-identical before and after the fix and cannot detect it. + + What actually changed is that the cause is now recoverable. The common + case is a kombu deserialization failure: the executor raised a + cloud-plugin exception class that this OSS image cannot import, so the + result backend HAS the error and this process cannot read it. The job + is finalized with "Executor failed without an error message" -- and + before, with nothing anywhere saying why. + + `exc_info` is asserted, not just the message: without the traceback the + log says a lookup failed but not what failed, which for an unimportable + exception class is the entire diagnosis. + """ + with caplog.at_level(logging.WARNING): + with patch(_PATCH_ASYNC_RESULT, side_effect=RuntimeError("backend down")): + self._call("failed-task-3b", cb_kwargs) + + # Matched by CONTENT, not by position. The callback also logs its own + # terminal ERROR ("agent_kv executor task failed: ..."), so taking + # `records[0]` would let this pass against a bare `pass` by picking up + # an unrelated record -- which it did on the first cut of this test. + matching = [ + r + for r in caplog.records + if r.levelno >= logging.WARNING and "result backend" in r.getMessage() + ] + assert len(matching) == 1, ( + "expected exactly one warning about the unreadable result backend; " + f"got {[r.getMessage() for r in caplog.records]}. Restoring the " + "bare `pass` makes this the failing assertion." + ) + record = matching[0] + assert "failed-task-3b" in record.getMessage(), record.getMessage() + assert record.exc_info is not None, ( + "logged without exc_info -- the traceback is the diagnosis when the " + "cause is an exception class this image cannot import" + ) + @patch(_PATCH_SEND_WEBHOOK) @patch(_PATCH_GET_CLIENT) def test_none_callback_kwargs_uses_empty_defaults( @@ -331,15 +379,46 @@ def test_agent_kv_error_is_configured_to_retry_before_giving_up(self): assert agent_kv_error.max_retries == 3 assert Exception in agent_kv_error.autoretry_for - def test_agent_kv_error_matches_its_siblings_failure_posture(self): - """`agent_kv_complete` re-raises; these two are the success and failure - halves of one contract and must not disagree about what a failed - finalize means. + @patch(_PATCH_SEND_WEBHOOK) + @patch(_PATCH_GET_CLIENT) + def test_agent_kv_error_matches_its_siblings_failure_posture( + self, mock_get_client, mock_send_webhook, cb_kwargs + ): + """Both callbacks must RAISE when finalize fails -- neither may swallow. + + These two are the success and failure halves of one contract: whichever + one runs is the SOLE terminalizer of the job, so a finalize that fails + silently leaves the row in RUNNING until the sweep overwrites the real + error with "Job timed out". `agent_kv_error` used to catch, log and + `return None`, which the consumer records as SUCCESS -- no retry, no + dead letter, no failed-task record. + + The previous version of this test asserted + `agent_kv_complete is not None` and `agent_kv_error.max_retries is not + None`, which is true of essentially any task object and could not fail + for the reason the test names. It is the behaviour that has to be + pinned, so both callbacks are actually driven here with a finalize that + blows up. """ from ide_callback.agent_kv_tasks import agent_kv_complete, agent_kv_error - assert agent_kv_complete is not None - assert agent_kv_error.max_retries is not None + api = MagicMock() + api.agent_kv_finalize.side_effect = RuntimeError("backend unreachable") + mock_get_client.return_value = api + + with pytest.raises(RuntimeError): + agent_kv_complete( + {"success": True, "data": {"output": {}}, "error": None}, cb_kwargs + ) + + with pytest.raises(RuntimeError): + with patch(_PATCH_ASYNC_RESULT, return_value=MagicMock(result="boom")): + agent_kv_error.run("failed-task-posture", cb_kwargs) + + # And neither may fire a webhook off a finalize that did not happen -- + # telling the caller a job is terminal when the row never moved is + # worse than telling them nothing. + assert not mock_send_webhook.called # --------------------------------------------------------------------------- diff --git a/workers/scheduler/agent_kv_tasks.py b/workers/scheduler/agent_kv_tasks.py index 6806299c14..117723f3cc 100644 --- a/workers/scheduler/agent_kv_tasks.py +++ b/workers/scheduler/agent_kv_tasks.py @@ -66,7 +66,15 @@ def agent_kv_sweep() -> dict[str, Any]: # Logged here as well as backend-side: this is the only place that records # the periodic actually FIRED. The counts alone cannot distinguish "ran and # found nothing" from "never ran", which is precisely the failure this - # task pair shipped with (see the backend migration that schedules them). + # task pair shipped with. + # + # Nothing in this repo schedules these. The cloud chart's + # `backend.agentKvCronJobs` runs the equivalent management commands and is + # the single owner (migration `0004_pg_periodic_tasks` used to seed + # PgPeriodicTask rows as a second owner and now removes them). A + # self-hosted OSS operator who wants these tasks driven through the PG + # scheduler registers the rows themselves with `pg_owned: True` -- see + # `docs/agent-kv-api.md` §12. logger.info("agent_kv.sweep completed: %s", result) return result diff --git a/workers/tests/test_llm_bearing_ops.py b/workers/tests/test_llm_bearing_ops.py index 77a602e5cd..7b7e4c8a33 100644 --- a/workers/tests/test_llm_bearing_ops.py +++ b/workers/tests/test_llm_bearing_ops.py @@ -17,8 +17,22 @@ from executor.tasks import _LLM_BEARING_OPS #: Operations known to drive at least one LLM call on every successful run. -#: Add to this list and the set together; a new paid op that lands in neither is -#: exactly the failure this file exists to catch. +#: Add to this list and the set together. +#: +#: **Known limitation of this file.** These are two literals, so the +#: assertions below can only fail when the copies DISAGREE -- never when both +#: are wrong together. A new paid op declared in neither still ships with no +#: billing alarm. The derived version, which asserts every operation in the +#: backend's `EXTRACTOR_ROUTES` is in `_LLM_BEARING_OPS` and so cannot be +#: satisfied by forgetting both, is +#: `backend/agent_kv/tests/test_queue_wiring_is_derived.py` +#: (`test_every_routed_operation_has_a_billing_backstop`). It lives there +#: because `EXTRACTOR_ROUTES` is not importable from this test venv, and +#: `executor.tasks` is not importable from the backend's -- neither side can +#: import both, which is why this pair exists at all. +#: +#: Kept rather than replaced: it covers the four non-Agent-KV paid ops, which +#: `EXTRACTOR_ROUTES` says nothing about. PAID_OPERATIONS = { "answer_prompt", "single_pass_extraction", diff --git a/workers/tests/test_queue_consumer_wiring.py b/workers/tests/test_queue_consumer_wiring.py index 6f98c2675f..14fc562cc7 100644 --- a/workers/tests/test_queue_consumer_wiring.py +++ b/workers/tests/test_queue_consumer_wiring.py @@ -14,6 +14,20 @@ no ``agentic_kv`` plugin, so a fleet listing that queue would accept work nothing can drain -- the same silent failure, arrived at from the other side. +**These names are literals, and that is a known limitation.** Re-enabling the +``kv`` extractor is one ``EXTRACTOR_ROUTES`` entry, after which +``celery_executor_agentic_kv`` must be SERVED -- and the inverse assertion +above would have to be deleted to go green, which is the one moment a guard +should not be getting deleted. The derived version of both directions lives in +``backend/agent_kv/tests/test_queue_wiring_is_derived.py``, where +``EXTRACTOR_ROUTES`` is actually importable (the backend package is not +importable from this test venv -- that asymmetry is why the duplication +exists). That suite also covers ``run-worker-docker.sh``, which this one does +not reach. + +Keeping both is deliberate: two independent statements of the same fact, one of +which does not depend on the routing table being correct. + An earlier version of this docstring described only the first direction and narrated how ``celery_executor_agentic_kv`` ought to be wired in. That is the one thing a guard with an inverted assertion must not say: a reader checking @@ -118,6 +132,18 @@ def test_test_compose_does_not_wire_the_queue_onto_the_dead_celery_var(): That drained nothing on the PG transport. If someone re-adds it, the queue looks configured while the lane still hangs -- so fail loudly. + + Scoped to TEST_COMPOSE deliberately, and NOT parametrized over both files + -- review asked for the parametrization and this is why it is wrong here. + `docker/docker-compose.yaml` legitimately sets `CELERY_QUEUES_EXECUTOR` on + `worker-pg-executor` (it is consumed by `run-worker-docker.sh:281` and by + `workers/executor/worker.py` for anyone still launching the Celery + executor), so parametrizing would turn this into an immediate red on a + config that is vestigial rather than wrong. + + What generalizes is not "the variable must be absent" but "the variable + must never advertise a queue the PG consumer does not serve" -- which is + the next test, and which DOES run against both files. """ env = _service_env(TEST_COMPOSE, "worker-pg-executor") assert DEAD_CELERY_VAR not in env, ( @@ -126,6 +152,40 @@ def test_test_compose_does_not_wire_the_queue_onto_the_dead_celery_var(): ) +@pytest.mark.parametrize( + ("compose_path", "label"), + [(DEV_COMPOSE, "dev compose"), (TEST_COMPOSE, "e2e test compose")], +) +def test_the_dead_celery_var_never_advertises_an_unserved_queue(compose_path, label): + """The generalized form of the test above, over both compose files. + + Wherever `CELERY_QUEUES_EXECUTOR` survives, it must stay a SUBSET of what + the PG consumer actually drains. The hazard is not the variable existing -- + it is the variable drifting into a superset, because then a reader + comparing the two lists concludes a queue is served when nothing drains it, + and the Celery path that would have drained it is disabled. + + Concretely this fails if someone adds `celery_executor_agentic_kv` to the + Celery list while the PG list correctly omits it. + """ + env = _service_env(compose_path, "worker-pg-executor") + dead = env.get(DEAD_CELERY_VAR) + if dead is None: + pytest.skip(f"{label} does not set {DEAD_CELERY_VAR}") + + pg_raw = env.get(PG_QUEUE_VAR) + assert pg_raw is not None, f"{label}: no {PG_QUEUE_VAR} to compare against" + + extra = _queues(dead) - _queues(pg_raw) + assert not extra, ( + f"{label}: {DEAD_CELERY_VAR} advertises {sorted(extra)}, which " + f"{PG_QUEUE_VAR} does not serve. {DEAD_CELERY_VAR} is read only by the " + f"disabled Celery executor, so nothing drains those -- work routed " + f"there is accepted and never runs. Add them to {PG_QUEUE_VAR} or drop " + f"them here." + ) + + def test_run_worker_pg_executor_role_omits_the_unserved_kv_queue(): """The host-run fleet's hardcoded role default must match the compose stacks. From c9b8b8db046cffa5807592c7325ae2c7c0c397a2 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Mon, 12 Oct 2026 10:11:40 +0530 Subject: [PATCH 19/54] UN-4232 [FIX] Four round-2 Greptile findings, two of them holes my own fixes opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1. `_never_dispatched` lost a case when I dropped its `status == PENDING` arm. Dropping it was right for the reason given -- the cancel has already rewritten `status`, so that test could only ever be answered from the stale object -- but `dispatched_at` alone is not enough. `dispatch_job` wraps `_record_dispatch` in a try/except on purpose: the task is already queued, so a bookkeeping failure must not be reported as a failed dispatch. A job can therefore be RUNNING, with an executor spending money, while `dispatched_at` is still NULL, and the executor's first stage report is the only thing that recorded it. Cancelling there released a live executor's slot and the next submit could take it, exceeding the per-org cap. DELETE had the same path. The old `status == PENDING` arm had been catching this by accident, off the stale row. Now consults BOTH durable markers, read fresh: `dispatched_at`, and `stage` (written by `StageReportView` on the first progress report, and never touched by the cancel). Five DB-backed cases in `test_adapter_scoping.py` -- including the RUNNING-with-NULL-dispatched_at row and the original stale-snapshot interleaving -- plus a view test asserting the query consults both fields, so neither can be dropped again. Mutation-checked: removing the `stage` marker fails three. P1. The migration fix could not reach the environments that needed it. Django records a migration as applied and never re-runs it, so rewriting 0004's forward operation to DELETE the seeded rows did nothing at all in every dev namespace that had already applied the seeding version -- while changing behaviour only for fresh installs. Its own docstring claimed to clean up environments it could not touch. 0004 is restored to seeding (with a note that 0007 supersedes it) and the removal is now `0007_remove_agent_kv_periodic_tasks`. A fresh install seeds then deletes; an already-seeded one just deletes; both land on zero rows. Verified against a real Postgres in all three states -- fresh, pre-seeded, and reverse-then-forward -- with the five dashboard_metrics periodics untouched each time. 0007 reads the row names out of 0004 rather than re-declaring them, and a test asserts 0004 still seeds, so folding the removal back into it fails loudly. P2. My `Enum:`/`REGEX:` fix over-rejected valid free text. Comparing the pre-colon segment against every name in `_KNOWN_FORMATS` meant `format: "number: of items"` -- and `"date: of issue"`, `"currency: USD"`, `"string: free text"` -- raised SchemaError, forcing authors to reword hints that are a SUPPORTED way to describe a key. That is a worse failure than the one being fixed: the original bug silently disabled validation for one key, this refused the whole schema. Scoped to `_ARGUMENT_FORMATS` (`enum`, `regex`) -- the only two that take an argument, so the only two where `name:arg` can be a mis-cased format. There is no such thing as `number:`. Verified across a 14-case matrix; mutation-checked. P2. The e2e module docstring still told operators to set the removed `AGENT_KV_E2E_BAD_KEY_JOB=1` and run `-k bad_llm_key`, a selector that matches nothing since the rename. Rewritten for `AGENT_KV_E2E_BAD_LLM_ADAPTER`, with the three adapter ids the accepted-submit scenarios need, and noting the new arrangement needs no special stack -- the bad credential is swapped in for the `llm` role on one submit, so the rest of the module still completes. Same correction in `docs/agent-kv-api.md` §11. Verification: agent_kv 302 passed, workers 74 passed 1 skipped, agent-kv-schema 143 passed. Migrations 0004/0007 exercised forward, reverse and re-forward against a real Postgres from both a fresh and a pre-seeded database; `makemigrations --check` clean. ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 49 ++++--- .../migrations/0004_pg_periodic_tasks.py | 127 +++++++++--------- .../0007_remove_agent_kv_periodic_tasks.py | 117 ++++++++++++++++ .../agent_kv/tests/test_adapter_scoping.py | 110 +++++++++++++++ backend/agent_kv/tests/test_job_views.py | 50 +++++-- .../tests/test_periodic_tasks_migration.py | 78 +++++++++-- docs/agent-kv-api.md | 15 ++- tests/e2e/agent_kv/test_agent_kv_e2e.py | 39 ++++-- .../src/unstract/agent_kv_schema/compile.py | 28 +++- .../agent-kv-schema/tests/test_compile.py | 29 ++++ 10 files changed, 523 insertions(+), 119 deletions(-) create mode 100644 backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index 93d1699167..28b021a84d 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -113,23 +113,42 @@ def _never_dispatched(job) -> bool: a smaller version of the original over-release bug this narrowing was written to fix, reintroduced by trusting the pre-cancel snapshot. - Only `dispatched_at` is consulted, and `status` deliberately is not: by the + TWO durable markers are consulted, and `status` is not one of them. By the time this runs the cancel has already rewritten `status` to CANCELLED, so - the old `status == PENDING` test could only ever be answered from the stale - object. `dispatched_at` is the durable marker of "an executor may exist" - and the cancel never touches it. - - **Residual window, stated rather than papered over.** `dispatch_job` - enqueues and only then writes `dispatched_at` (`_record_dispatch`). A cancel - landing between those two still sees NULL and still releases early. The - consequence is bounded and self-correcting: one extra concurrent slot for - the remainder of that one run, after which the executor's finalize releases - again (`release` is an idempotent zrem). Closing it completely means - stamping a dispatch-intent marker before the enqueue, which changes what - `dispatched_at` means to both sweep phases -- a larger change than the - defect warrants. + the original `status == PENDING` test could only ever be answered from the + stale object -- which is what made it unsafe to keep. But dropping it + outright (the first cut of this fix) lost a case it had been covering: + + * `dispatched_at` -- written by post-enqueue bookkeeping. + * `stage` -- written by `StageReportView` on the executor's FIRST progress + report, which also promotes PENDING/DISPATCHED to RUNNING + (`internal_views.py:152-156`). The cancel never touches it. + + The second is load-bearing because the bookkeeping can legitimately fail: + `dispatch_job` wraps `_record_dispatch` in a try/except precisely so a + failure there cannot be reported as a failed dispatch (the task is already + queued). A job can therefore be RUNNING, with an executor spending money, + while `dispatched_at` is still NULL -- and the stage report is the only + thing that recorded it. Checking `dispatched_at` alone released that slot; + the old `status == PENDING` arm had been catching it by accident, off the + stale row. + + So: no executor can exist only if NEITHER marker is set, read fresh. + + **Residual window, stated rather than papered over.** A cancel landing + between the enqueue and the first of either write still sees both unset and + still releases early. Bounded and self-correcting: one extra concurrent slot + for the remainder of that one run, after which the executor's finalize + releases again (`release` is an idempotent zrem). Closing it completely + means stamping a dispatch-intent marker before the enqueue, which changes + what `dispatched_at` means to both sweep phases -- a larger change than the + residue warrants. """ - return not AgentKVJob.objects.filter(id=job.id, dispatched_at__isnull=False).exists() + return not ( + AgentKVJob.objects.filter(id=job.id) + .exclude(dispatched_at__isnull=True, stage="") + .exists() + ) def _fail_job_response(job, org_id: str, message: str, *, job_saved: bool) -> Response: diff --git a/backend/agent_kv/migrations/0004_pg_periodic_tasks.py b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py index c010a9b488..083e6aa5f0 100644 --- a/backend/agent_kv/migrations/0004_pg_periodic_tasks.py +++ b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py @@ -1,64 +1,64 @@ -"""Remove the Agent-KV maintenance periodics -- the CronJob owns the schedule. - -An earlier revision of this migration SEEDED two ``PgPeriodicTask`` rows -(``agent_kv.sweep`` at ``*/10``, ``agent_kv.ttl_cleanup`` at ``17 3 * * *``) on -the premise that nothing anywhere fired the two maintenance commands -- "no -``PgPeriodicTask``, no beat entry, no CronJob". **That premise was false when it -was written.** ``templates/backend/agent-kv-cronjobs.yaml`` in the cloud chart -already ran the same two management commands, on the same branch, added several -commits earlier. The seeding migration was written in response to a review -finding about the unscheduled sweep without noticing the finding had already -been answered elsewhere in the same change. - -Keeping both would have been worse than either alone: - -* **Two owners for one schedule.** The rows are seeded ``pg_owned: False``, and - the PG scheduler claims only ``WHERE pg_owned AND enabled`` - (``pg_queue/models.py``), so they are inert *today*. But the whole point of - that flag is that a rollout flips it -- and the moment it did, the ``*/10`` - rows and the CronJob's ``*/15`` ticks would both fire, colliding exactly at - ``:30`` and contending on the same job rows. The TTL cadences disagreed by - 24x (daily here, hourly in the chart), so whichever fired would be whichever - owner someone remembered. -* **Only one owner has operational bounds.** The CronJob carries - ``concurrencyPolicy: Forbid`` and ``activeDeadlineSeconds``; a PG periodic has - neither, so a hung sweep under the scheduler has nothing stopping it. -* **The feature only runs where the CronJob is.** This deployment routes - ``table`` only, and the ``agentic_table`` plugin ships in the cloud image. A - pure-OSS install cannot dispatch an Agent-KV job at all, so there is no - stranded row for a sweep to reap there. - -So the CronJob is the single owner, and this migration deletes the rows rather -than merely stopping at not creating them -- any environment that applied the -seeding revision (dev namespaces did) still has them, and leaving them to be -switched on by a future global ``pg_owned`` flip is precisely the hazard being -removed. - -**Self-hosted OSS operators who enable the Agent-KV API must schedule -``manage.py agent_kv_sweep`` and ``manage.py agent_kv_ttl_cleanup`` -themselves** (cron, a systemd timer, or their own ``PgPeriodicTask`` rows with -``pg_owned: True``). Without them a stranded job holds its concurrency slot -until the 6h Redis TTL, and ``AGENT_KV_RESULT_TTL_DAYS`` is advisory -- -staged documents and results stay in the bucket indefinitely, which is a -retention failure rather than a disk-usage one. ``docs/agent-kv-api.md`` §12 -carries this in the deploy checklist. - -Reverse re-creates the rows (still inert) so the migration is reversible and a -``migrate agent_kv 0003`` lands back on the previous tree's state exactly. +"""Schedule the two Agent-KV maintenance periodics. **SUPERSEDED by 0007.** + +Read `0007_remove_agent_kv_periodic_tasks.py` before acting on anything here. +In short: the premise stated below -- that nothing anywhere fired these two +tasks, "no ``PgPeriodicTask``, no beat entry, no CronJob" -- was already false +when it was written. The cloud chart's `backend.agentKvCronJobs` ran the same +two management commands, added earlier on this same branch, so this migration +registered a SECOND owner for one schedule. 0007 deletes these rows and the +CronJob is the sole owner. + +This file is deliberately left seeding rather than rewritten into a no-op. +Django records a migration as applied and never re-runs it, so editing this +file would do nothing at all in the environments that actually have the rows +(dev namespaces applied it) while quietly rewriting history for fresh ones. The +removal has to be a new migration, and the cost of leaving this one honest is +two trivial writes on a fresh install that 0007 immediately undoes. + +Everything below describes the original intent and is kept for the record. + +---- + +Both tasks were registered in ``workers/scheduler/agent_kv_tasks.py`` and both +internal endpoints existed, but nothing anywhere created a row to fire them -- +no ``PgPeriodicTask``, no beat entry, no CronJob. The module's own docstring +said an operator must register them by hand, which in practice means they never +ran. + +What that costs, concretely: + +* ``run_sweep`` never runs, so an OOM-killed executor leaves its job row + non-terminal forever -- ``link_error`` never fires for a killed process -- and + the row holds its concurrency slot until the 6h Redis TTL. Enough of those and + the org's allowance is exhausted and new submits start getting 429s. +* ``run_ttl_cleanup`` never runs, so ``AGENT_KV_RESULT_TTL_DAYS`` is advisory: + every staged customer document and every result stays in the bucket + indefinitely. That is a retention-policy failure, not just a disk-usage one. + +Follows the ``dashboard_metrics`` precedent (migrations 0004-0006) rather than +inventing a mechanism: the same ``PgPeriodicTask`` table, the same +``update_or_create`` shape so a re-run is idempotent, and ``pg_owned: False`` so +applying this migration does not itself start firing them -- the rollout flag +decides that, exactly as it does for the metrics periodics. + +Cadences are deliberately offset from each other and off the hour: the sweep is +cheap and wants to be frequent (a stuck job holds a slot), the TTL cleanup is a +bulk delete that should not land on the same tick as anything else. """ from django.db import migrations -#: What the seeding revision wrote, kept ONLY so forward can delete exactly -#: those rows by name and reverse can restore them. Not a live schedule -- -#: the cadences that run are in the cloud chart's `backend.agentKvCronJobs`. PG_PERIODIC_TASKS = [ { "name": "agent_kv_sweep", + # Wire name registered by workers/scheduler/agent_kv_tasks.py. "task_name": "agent_kv.sweep", "queue": "scheduler", "task_args": [], "task_kwargs": {}, + # Every 10 minutes: a stuck job holds a concurrency slot, and the + # handler is idempotent and batch-capped server-side, so a missed tick + # costs nothing and a frequent one is cheap. "cron_string": "*/10 * * * *", }, { @@ -67,25 +67,19 @@ "queue": "scheduler", "task_args": [], "task_kwargs": {}, + # Daily at 03:17 UTC. Off the hour and off the dashboard_metrics + # cleanups (02:00 / 03:00) so a bulk object-store delete does not land + # on the same tick as another bulk job. "cron_string": "17 3 * * *", }, ] -def remove_pg_periodic_tasks(apps, schema_editor): - """Delete the seeded rows. Idempotent: a fresh install matches nothing.""" +def create_pg_periodic_tasks(apps, schema_editor): # snake_case, not the usual `PgPeriodicTask = apps.get_model(...)` Django # idiom: it is a local variable, and sonar's S117 reads the CamelCase name # as a naming violation. The historical-model object is the same either way. periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") - periodic_task.objects.filter( - name__in=[spec["name"] for spec in PG_PERIODIC_TASKS] - ).delete() - - -def create_pg_periodic_tasks(apps, schema_editor): - """Reverse only. Restores the rows the seeding revision created, inert.""" - periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") for spec in PG_PERIODIC_TASKS: periodic_task.objects.update_or_create( name=spec["name"], @@ -99,18 +93,27 @@ def create_pg_periodic_tasks(apps, schema_editor): # endpoints sweep across every organization. "org_id": "", "enabled": True, + # Inert until the rollout flag decides otherwise; never fired by + # applying this migration. Same posture as dashboard_metrics. "pg_owned": False, }, ) +def remove_pg_periodic_tasks(apps, schema_editor): + periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") + periodic_task.objects.filter( + name__in=[spec["name"] for spec in PG_PERIODIC_TASKS] + ).delete() + + class Migration(migrations.Migration): dependencies = [ ("agent_kv", "0003_agentkvjob_cleanup_failed_at"), - # The table this operates on. + # The table this seeds. ("pg_queue", "0003_pgperiodictask"), ] operations = [ - migrations.RunPython(remove_pg_periodic_tasks, create_pg_periodic_tasks), + migrations.RunPython(create_pg_periodic_tasks, remove_pg_periodic_tasks), ] diff --git a/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py new file mode 100644 index 0000000000..bd2767aa7b --- /dev/null +++ b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py @@ -0,0 +1,117 @@ +"""Remove the Agent-KV maintenance periodics -- the CronJob owns the schedule. + +Migration 0004 seeded two ``PgPeriodicTask`` rows (``agent_kv.sweep`` at +``*/10``, ``agent_kv.ttl_cleanup`` at ``17 3 * * *``) on the stated premise +that nothing anywhere fired the two maintenance commands -- "no +``PgPeriodicTask``, no beat entry, no CronJob". **That premise was false when +it was written.** ``templates/backend/agent-kv-cronjobs.yaml`` in the cloud +chart already ran the same two management commands, added several commits +earlier on the same branch. 0004 was written in response to a review finding +about the unscheduled sweep without noticing the finding had already been +answered elsewhere in the same change. + +Keeping both would have been worse than either alone: + +* **Two owners for one schedule.** The rows are seeded ``pg_owned: False`` and + the PG scheduler claims only ``WHERE pg_owned AND enabled`` + (``pg_queue/models.py``), so they are inert *today*. But that flag exists to + be flipped by a rollout -- and the moment it was, the ``*/10`` rows and the + CronJob's ``*/15`` ticks would both fire, colliding exactly at ``:30`` and + contending on the same job rows. The TTL cadences disagreed by 24x (daily + here, hourly in the chart), so whichever ran would be whichever owner someone + happened to remember. +* **Only one owner has operational bounds.** The CronJob carries + ``concurrencyPolicy: Forbid`` and ``activeDeadlineSeconds``; a PG periodic has + neither, so a hung sweep under the scheduler has nothing stopping it. +* **The feature only runs where the CronJob is.** This deployment routes + ``table`` only, and the ``agentic_table`` plugin ships in the cloud image. A + pure-OSS install cannot dispatch an Agent-KV job at all, so there is no + stranded row for a sweep to reap there. + +**Why this is a new migration and not an edit to 0004.** Django records a +migration as applied and never re-runs it. Rewriting 0004's forward operation +would therefore have had no effect whatsoever in exactly the environments that +have the rows -- every dev namespace that applied the seeding version -- while +changing behaviour only for fresh installs. The first cut of this fix did that, +and its own docstring claimed to clean up environments it could not reach. + +So: 0004 stays as it was, this removes. A fresh install seeds and then deletes +(two trivial writes); an environment that already applied 0004 just deletes. +Both land on the same state, which is the point. + +The delete is scoped BY NAME and is idempotent: a fresh install's 0004 created +exactly these two rows, and an unscoped delete here would wipe the +dashboard_metrics periodics this table is shared with. + +**Self-hosted OSS operators who enable the Agent-KV API must schedule +``manage.py agent_kv_sweep`` and ``manage.py agent_kv_ttl_cleanup`` +themselves** (cron, a systemd timer, or their own ``PgPeriodicTask`` rows with +``pg_owned: True``). Without them a stranded job holds its concurrency slot +until the 6 h Redis TTL, and ``AGENT_KV_RESULT_TTL_DAYS`` is advisory -- +staged documents and results stay in the bucket indefinitely, which is a +retention failure rather than a disk-usage one. ``docs/agent-kv-api.md`` §12 +carries this in the deploy checklist. + +Reverse re-creates the rows (still inert), so ``migrate agent_kv 0006`` lands +back on the previous release's state exactly rather than leaving an environment +with no rows and no way to restore them. +""" + +import importlib + +from django.db import migrations + +#: The rows 0004 created, read from 0004 itself rather than re-declared here, +#: so the two files cannot drift into deleting a different set than was seeded. +#: `importlib` because the module name starts with a digit and cannot be a +#: plain `import` (same reason `tests/test_periodic_tasks_migration.py` does +#: it this way). +PG_PERIODIC_TASKS = importlib.import_module( + "agent_kv.migrations.0004_pg_periodic_tasks" +).PG_PERIODIC_TASKS + + +def remove_pg_periodic_tasks(apps, schema_editor): + """Delete the seeded rows. Idempotent: a fresh install matches nothing.""" + # snake_case, not the usual `PgPeriodicTask = apps.get_model(...)` Django + # idiom: it is a local variable, and sonar's S117 reads the CamelCase name + # as a naming violation. The historical-model object is the same either way. + periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") + periodic_task.objects.filter( + name__in=[spec["name"] for spec in PG_PERIODIC_TASKS] + ).delete() + + +def restore_pg_periodic_tasks(apps, schema_editor): + """Reverse only. Restores 0004's rows, still inert (`pg_owned: False`). + + A reverse that restored them ENABLED would turn a rollback into the + double-fire this migration exists to prevent. + """ + periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") + for spec in PG_PERIODIC_TASKS: + periodic_task.objects.update_or_create( + name=spec["name"], + defaults={ + "task_name": spec["task_name"], + "queue": spec["queue"], + "task_args": spec["task_args"], + "task_kwargs": spec["task_kwargs"], + "cron_string": spec["cron_string"], + # Platform-wide, not per-org (spec §5.4): both internal + # endpoints sweep across every organization. + "org_id": "", + "enabled": True, + "pg_owned": False, + }, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("agent_kv", "0006_agentkvjob_adapters"), + ] + + operations = [ + migrations.RunPython(remove_pg_periodic_tasks, restore_pg_periodic_tasks), + ] diff --git a/backend/agent_kv/tests/test_adapter_scoping.py b/backend/agent_kv/tests/test_adapter_scoping.py index a8984a8cad..b5a6f59c44 100644 --- a/backend/agent_kv/tests/test_adapter_scoping.py +++ b/backend/agent_kv/tests/test_adapter_scoping.py @@ -321,3 +321,113 @@ def test_no_adapter_metadata_is_ever_stored_on_the_job(self): assert isinstance(value, str), (role, value) # A bare UUID string, not a serialized adapter. uuid.UUID(value) + + +class TestNeverDispatchedAgainstRealRows(TestCase): + """`_never_dispatched` against the database, because mocks hid it twice. + + Round-2 C1 was "it read the stale in-memory row". The first fix for that + dropped the `status == PENDING` arm and lost a second case: a job RUNNING + with `dispatched_at` still NULL, which happens whenever post-enqueue + bookkeeping fails (`dispatch_job` deliberately swallows that -- the task is + already queued, so a failure there must not be reported as a failed + dispatch). + + Both fixes were written against patched managers, which is why neither + caught the other. These use real rows. + """ + + def setUp(self): + self.org = Organization.objects.create( + display_name="Acme", organization_id="org_acme", name="acme" + ) + UserContext.set_organization_identifier(None) + + def _job(self, **kwargs): + from agent_kv.models import AgentKVJob, JobExtractor + + job = AgentKVJob( + organization_id=self.org.id, extractor=JobExtractor.TABLE, **kwargs + ) + job.save() + return job + + def test_a_pristine_pending_job_has_no_executor(self): + """The one case the slot release exists for.""" + from agent_kv.execution_views import _never_dispatched + from agent_kv.models import JobStatus + + job = self._job(status=JobStatus.PENDING) + + assert _never_dispatched(job) is True + + def test_a_dispatched_job_has_an_executor(self): + from agent_kv.execution_views import _never_dispatched + from agent_kv.models import JobStatus + from django.utils import timezone + + job = self._job(status=JobStatus.DISPATCHED, dispatched_at=timezone.now()) + + assert _never_dispatched(job) is False + + def test_a_job_with_a_reported_stage_has_an_executor_even_with_no_dispatched_at( + self, + ): + """The hole the first fix opened. + + `dispatched_at` NULL and a stage recorded: bookkeeping failed, the + executor ran anyway and reported progress. Releasing here hands a live + executor's slot to the next submit. + """ + from agent_kv.execution_views import _never_dispatched + from agent_kv.models import JobStatus + + job = self._job( + status=JobStatus.RUNNING, + dispatched_at=None, + stage="table_extraction", + stages={"table_extraction": {"status": "running"}}, + ) + + assert _never_dispatched(job) is False, ( + "a RUNNING job with a reported stage has a live executor; its slot " + "belongs to the finalize callback" + ) + + def test_the_cancel_rewriting_status_does_not_resurrect_the_release(self): + """Why `status` is not one of the markers. + + By the time this runs the cancel has already written CANCELLED, so a + status-based test could only be answered from the stale object. The two + durable markers must still say "executor exists". + """ + from agent_kv.execution_views import _never_dispatched + from agent_kv.models import JobStatus + + job = self._job( + status=JobStatus.CANCELLED, dispatched_at=None, stage="table_extraction" + ) + + assert _never_dispatched(job) is False + + def test_a_stale_in_memory_row_cannot_override_the_database(self): + """The original C1 interleaving, end to end. + + The in-memory object is doctored to look pristine -- exactly what the + cancel path holds, having fetched it before `mark_terminal` -- while the + row in the database carries the dispatch. + """ + from agent_kv.execution_views import _never_dispatched + from agent_kv.models import AgentKVJob, JobStatus + from django.utils import timezone + + job = self._job(status=JobStatus.PENDING) + AgentKVJob.objects.filter(id=job.id).update( + status=JobStatus.DISPATCHED, dispatched_at=timezone.now() + ) + + # The snapshot the cancel path is holding. + job.status = JobStatus.PENDING + job.dispatched_at = None + + assert _never_dispatched(job) is False diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index 48d34b34e6..5d61eb6356 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -308,7 +308,7 @@ def test_cancel_win_releases_concurrency_slot(m_keys, m_jobs, m_mark_terminal, m job = AgentKVJob(status=JobStatus.PENDING) job.organization_id = "org1" m_jobs.get.return_value = job - _dispatched_in_db(m_jobs, False) + _executor_may_exist_in_db(m_jobs, False) resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) @@ -316,16 +316,20 @@ def test_cancel_win_releases_concurrency_slot(m_keys, m_jobs, m_mark_terminal, m m_release.assert_called_once_with("org1", str(job.id)) -def _dispatched_in_db(m_jobs, dispatched: bool) -> None: +def _executor_may_exist_in_db(m_jobs, may_exist: bool) -> None: """Wire the DB re-read `_never_dispatched` performs. It no longer reads `job.dispatched_at` off the in-memory row -- that row predates `mark_terminal`, and a dispatch completing in that gap made the cancel release a slot whose executor was still running (round-2 C1). It now - asks the database, so these tests have to say what the database would - answer. + asks the database for EITHER durable marker (`dispatched_at` set, or a + `stage` recorded by the executor's first progress report), so these tests + have to say what the database would answer. + + `may_exist=True` means the DB has evidence of an executor, so the slot must + NOT be released. """ - m_jobs.filter.return_value.exists.return_value = dispatched + m_jobs.filter.return_value.exclude.return_value.exists.return_value = may_exist # --------------------------------------------------------------------------- # (6c) cancel that LOSES the guard (job already terminal) must NOT release -- @@ -500,7 +504,7 @@ def test_delete_on_a_never_dispatched_job_DOES_release_the_slot( job.organization_id = "org1" job.dispatched_at = None m_jobs.get.return_value = job - _dispatched_in_db(m_jobs, False) + _executor_may_exist_in_db(m_jobs, False) resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) @@ -819,7 +823,7 @@ def test_cancel_on_a_never_dispatched_job_DOES_release_the_slot( job.organization_id = "org1" job.dispatched_at = None m_jobs.get.return_value = job - _dispatched_in_db(m_jobs, False) + _executor_may_exist_in_db(m_jobs, False) resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) @@ -853,7 +857,7 @@ def test_a_cancel_that_raced_a_dispatch_does_NOT_release_the_slot( job.organization_id = "org1" job.dispatched_at = None # the stale snapshot, as the cancel path sees it m_jobs.get.return_value = job - _dispatched_in_db(m_jobs, True) # what actually happened meanwhile + _executor_may_exist_in_db(m_jobs, True) # what actually happened meanwhile resp = ev.JobCancelView.as_view()(_authed(method="post"), job_id=uuid.uuid4()) @@ -876,9 +880,35 @@ def test_the_undispatched_check_asks_the_database_not_the_row(m_jobs): """ job = AgentKVJob(status=JobStatus.CANCELLED) job.dispatched_at = timezone.now() - _dispatched_in_db(m_jobs, False) + _executor_may_exist_in_db(m_jobs, False) assert ev._never_dispatched(job) is True - _dispatched_in_db(m_jobs, True) + _executor_may_exist_in_db(m_jobs, True) + assert ev._never_dispatched(job) is False + + +@mock.patch.object(AgentKVJob, "objects") +def test_a_reported_stage_counts_as_evidence_of_an_executor(m_jobs): + """The case dropping the `status` condition lost, pinned on the query. + + `dispatch_job` wraps `_record_dispatch` in a try/except so a bookkeeping + failure cannot be reported as a failed dispatch -- the task is already + queued. So a job can be RUNNING, with an executor spending money, while + `dispatched_at` is still NULL; the executor's first stage report is the only + thing that recorded it (`internal_views.py:152-156` writes `stage` and + promotes the status). + + Checking `dispatched_at` alone released that slot. The old + `status == PENDING` arm had been catching it by accident, off the stale row. + This asserts the query consults BOTH markers, so neither can be dropped + again: a filter on one field only would not produce this `exclude` call. + """ + job = AgentKVJob(status=JobStatus.CANCELLED) + job.dispatched_at = None + _executor_may_exist_in_db(m_jobs, True) + assert ev._never_dispatched(job) is False + + excluded = m_jobs.filter.return_value.exclude.call_args.kwargs + assert excluded == {"dispatched_at__isnull": True, "stage": ""}, excluded diff --git a/backend/agent_kv/tests/test_periodic_tasks_migration.py b/backend/agent_kv/tests/test_periodic_tasks_migration.py index f03c4a7c85..33b9d918b3 100644 --- a/backend/agent_kv/tests/test_periodic_tasks_migration.py +++ b/backend/agent_kv/tests/test_periodic_tasks_migration.py @@ -1,7 +1,7 @@ """One owner for the two maintenance schedules, and it is the CronJob. -History, because the test's shape only makes sense with it: an earlier revision -of migration 0004 SEEDED two ``PgPeriodicTask`` rows to fire +History, because the test's shape only makes sense with it: migration 0004 +SEEDS two ``PgPeriodicTask`` rows to fire ``agent_kv.sweep`` and ``agent_kv.ttl_cleanup``, answering a review finding about the sweep never running. The finding was real; the fix was redundant. ``templates/backend/agent-kv-cronjobs.yaml`` in the cloud chart already ran the @@ -15,10 +15,20 @@ put a ``*/10`` sweep against the chart's ``*/15`` -- colliding at ``:30``, contending on the same job rows -- with TTL cadences disagreeing 24x. -So 0004 now DELETES those rows and the CronJob is the sole owner. What these -tests defend is that decision, in the two ways it can be undone: someone -re-pointing the forward operation at the seeding function, or someone dropping -the reverse and making the migration one-way. +So **0007** deletes those rows and the CronJob is the sole owner. 0004 is left +seeding, deliberately: Django records a migration as applied and never re-runs +it, so editing 0004 would have had no effect in the environments that actually +have the rows (every dev namespace that applied it) while silently rewriting +history for fresh ones. The first cut of this fix did exactly that and claimed +in its own docstring to clean up environments it could not reach. Verified both +ways against a real database -- a fresh install seeds then deletes, an +already-seeded one just deletes, and both land on zero rows with the five +dashboard_metrics periodics untouched. + +What these tests defend is that arrangement, in the three ways it can be +undone: someone pointing 0007's forward operation at the restore function, +someone dropping its reverse and making it one-way, or someone "simplifying" +the removal back into an edit of 0004. The name/queue assertions are kept rather than deleted -- they still guard the reverse path, and the same wire names are what the CronJob's management @@ -32,7 +42,10 @@ from django.db import migrations # The module name starts with a digit, so it cannot be a plain `import`. -MIGRATION = importlib.import_module("agent_kv.migrations.0004_pg_periodic_tasks") +SEEDER = importlib.import_module("agent_kv.migrations.0004_pg_periodic_tasks") +MIGRATION = importlib.import_module( + "agent_kv.migrations.0007_remove_agent_kv_periodic_tasks" +) #: Wire names registered by `@worker_task(name=...)` in #: `workers/scheduler/agent_kv_tasks.py`. Duplicated as literals on purpose: @@ -73,14 +86,51 @@ def test_the_migration_still_has_a_runpython_operation(): def test_the_forward_operation_removes_the_rows_rather_than_seeding_them(): """The decision itself. - If this fails, someone has re-pointed forward at `create_pg_periodic_tasks` - and there are two owners for one schedule again. Read the migration's - docstring before changing it: the collision is at `:30`, and only the - CronJob side carries `concurrencyPolicy: Forbid` and a deadline. + If this fails, someone has re-pointed forward at the restore function and + there are two owners for one schedule again. Read the migration's docstring + before changing it: the collision is at `:30`, and only the CronJob side + carries `concurrencyPolicy: Forbid` and a deadline. """ assert _run_python_ops()[0].code is MIGRATION.remove_pg_periodic_tasks +def test_the_removal_is_its_own_migration_not_an_edit_to_the_seeder(): + """Django never re-runs an applied migration. + + 0004 is recorded as applied in every environment that has these rows, so + rewriting ITS forward operation cleans up nothing there -- it only changes + what a fresh install does. That was the first cut of this fix, and its + docstring claimed to clean environments it could not reach. + + So 0004 must still seed, and the removal must live in a later migration + that those environments have yet to apply. + """ + seeder_ops = [ + op + for op in SEEDER.Migration.operations + if isinstance(op, migrations.RunPython) + ] + + assert len(seeder_ops) == 1 + assert seeder_ops[0].code is SEEDER.create_pg_periodic_tasks, ( + "0004 must keep SEEDING. If the removal was folded back into it, every " + "environment that already applied 0004 keeps its rows forever and this " + "fix reaches none of them." + ) + assert MIGRATION is not SEEDER + assert ("agent_kv", "0006_agentkvjob_adapters") in MIGRATION.Migration.dependencies + + +def test_the_removal_targets_exactly_what_the_seeder_created(): + """One source of truth for the row names. + + 0007 reads `PG_PERIODIC_TASKS` out of 0004 rather than re-declaring it, so + the two cannot drift into deleting a different set than was seeded. If + someone copies the list instead, this fails. + """ + assert MIGRATION.PG_PERIODIC_TASKS is SEEDER.PG_PERIODIC_TASKS + + def test_the_migration_is_reversible(): """`migrate agent_kv 0003` must land back on the previous tree's state. @@ -90,7 +140,7 @@ def test_the_migration_is_reversible(): """ op = _run_python_ops()[0] - assert op.reverse_code is MIGRATION.create_pg_periodic_tasks + assert op.reverse_code is MIGRATION.restore_pg_periodic_tasks assert op.reverse_code is not migrations.RunPython.noop @@ -149,7 +199,7 @@ def test_the_restored_rows_are_inert(): A reverse that restored them ENABLED would turn a rollback into the double-fire this migration exists to prevent. """ - source = inspect.getsource(MIGRATION.create_pg_periodic_tasks) + source = inspect.getsource(MIGRATION.restore_pg_periodic_tasks) assert '"pg_owned": False' in source assert "update_or_create" in source, "re-running must stay idempotent" @@ -160,7 +210,7 @@ def test_restored_rows_are_platform_wide(): non-empty `org_id` would scope them to one tenant and silently leave every other org unswept. """ - source = inspect.getsource(MIGRATION.create_pg_periodic_tasks) + source = inspect.getsource(MIGRATION.restore_pg_periodic_tasks) assert '"org_id": ""' in source assert '"enabled": True' in source diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 316cd85a1b..63defb6cb5 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -1055,8 +1055,19 @@ beyond `docker compose up`: `UNSTRACT_BACKEND_URL`, `AGENT_KV_E2E=1` and the `AGENT_KV_*` keys exported: `AGENT_KV_E2E=1 UNSTRACT_BACKEND_URL=http://localhost:8000 pytest tests/e2e/agent_kv`. - Two scenarios are operator-gated on top of that: the bad-LLM-key scenario - (`AGENT_KV_E2E_BAD_KEY_JOB=1`, see the test module docstring) and the + Every scenario whose submit must be ACCEPTED also needs the three adapter + ids exported — `AGENT_KV_E2E_LLM_ADAPTER`, `AGENT_KV_E2E_LITE_LLM_ADAPTER`, + `AGENT_KV_E2E_X2TEXT_ADAPTER` — since `table` submits name the caller's own + platform adapters (§3a) and the API validates each against the Bearer key's + organization. Create them once in the target org (an LLM adapter, a cheaper + LLM adapter, and an LLMWhisperer X2TEXT adapter). Without them those + scenarios skip rather than fail. + + Two scenarios are operator-gated on top of that: the bad-credential + scenario (`AGENT_KV_E2E_BAD_LLM_ADAPTER`, the id of an LLM adapter + deliberately carrying an invalid provider credential — see the test module + docstring; it replaces the removed `AGENT_KV_E2E_BAD_KEY_JOB`, which this + path no longer reads) and the completion-webhook scenario, which needs `AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=1` set BOTH on the ide-callback worker (it waives the webhook SSRF guards — https scheme + diff --git a/tests/e2e/agent_kv/test_agent_kv_e2e.py b/tests/e2e/agent_kv/test_agent_kv_e2e.py index 8790f99843..bbbc937820 100644 --- a/tests/e2e/agent_kv/test_agent_kv_e2e.py +++ b/tests/e2e/agent_kv/test_agent_kv_e2e.py @@ -47,18 +47,33 @@ (see its own docstring) and ``tests/groups.yaml``'s ``e2e-agent-kv`` entry pins ``parallel: false`` for the same reason -- see that scenario for why. -**A third, operator-driven gate: ``AGENT_KV_E2E_BAD_KEY_JOB=1``.** One -scenario here (``test_bad_llm_key_ends_failed``) needs a *dispatched* job to -end FAILED, which no request-time check can produce -- the only deterministic -way to get there is to run the platform with a deliberately invalid -``AGENT_KV_LLM_API_KEY`` so the executor's LLM calls all fail. That is a -whole-stack configuration, not something a test can arrange, so the scenario -skips unless an operator sets ``AGENT_KV_E2E_BAD_KEY_JOB=1`` to declare "this -stack is running with a bad Agent-KV LLM key on purpose". Note that every -other scenario in this module that needs a job to COMPLETE will (correctly) -fail on such a stack -- run this one on its own, e.g.:: - - AGENT_KV_E2E=1 AGENT_KV_E2E_BAD_KEY_JOB=1 pytest tests/e2e/agent_kv -k bad_llm_key +**A third, operator-driven gate: ``AGENT_KV_E2E_BAD_LLM_ADAPTER``.** One +scenario here (``test_a_bad_adapter_credential_ends_failed``) needs a +*dispatched* job to end FAILED, which no request-time check can produce -- the +only deterministic way there is a credential the executor's LLM calls cannot +use. That is a platform-side arrangement, not something a test can mint, so +the scenario skips unless an operator exports the id of an LLM adapter in the +key's organization carrying a deliberately invalid provider credential:: + + AGENT_KV_E2E=1 \ + AGENT_KV_E2E_LLM_ADAPTER=... \ + AGENT_KV_E2E_LITE_LLM_ADAPTER=... \ + AGENT_KV_E2E_X2TEXT_ADAPTER=... \ + AGENT_KV_E2E_BAD_LLM_ADAPTER=... \ + pytest tests/e2e/agent_kv -k bad_adapter_credential + +Unlike the arrangement this replaces, it needs no special stack and can run +alongside every other scenario: the bad credential is swapped in for the +``llm`` ROLE on that one submit, so the other two roles stay valid and the rest +of the module still completes normally. + +It replaces ``AGENT_KV_E2E_BAD_KEY_JOB=1`` plus an invalid +``AGENT_KV_LLM_API_KEY``, which stopped working when the table path moved onto +caller-supplied adapters -- that variable is not read on this path at all, so +an operator following those instructions got a job that ran on the VALID +adapter credential, reached ``completed``, and failed the test's own +``status == "failed"`` assertion. The old selector ``-k bad_llm_key`` also no +longer matches anything. What it asserts is the *shape* of the failure, not its text: the job reaches ``failed`` and the ``error`` the customer sees is user-safe -- no filesystem diff --git a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py index b3aa72e075..1bb590ade3 100644 --- a/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py +++ b/unstract/agent-kv-schema/src/unstract/agent_kv_schema/compile.py @@ -216,6 +216,13 @@ def _reject_unsafe_regex(path: str, pattern: str) -> None: #: and got none, with no error anywhere to find it by. _KNOWN_FORMATS = frozenset({"string", "number", "date", "currency", "enum", "regex"}) +#: The only two formats that take an ARGUMENT, written `enum:a,b` / `regex:^x$`. +#: Everything else in `_KNOWN_FORMATS` is a bare name, so `"number: of items"` +#: cannot be a mis-cased format -- there is no such thing as `number:` -- and +#: must stay a free-text hint. Scoping the colon check to these two is what +#: keeps the `Enum:`/`REGEX:` guard from rejecting ordinary prose. +_ARGUMENT_FORMATS = frozenset({"enum", "regex"}) + def _reject_unusable_formats(key_specs, array_specs) -> None: """Refuse declared formats that can never validate anything, or always fail. @@ -234,6 +241,12 @@ class for the two formats that take an ARGUMENT, and the one the first they arrive as the whole raw string, which no bare-name comparison matches. Validation was silently off for the key -- the precise outcome the first bullet exists to prevent, reached by a route it did not cover. + + Only ``enum`` and ``regex`` are checked this way (``_ARGUMENT_FORMATS``). + Checking every known name against the pre-colon segment rejected + ordinary prose -- ``"number: of items"``, ``"date: of issue"`` -- which + is a worse failure than the one being fixed, because free text is a + SUPPORTED way to describe a key. * ``format: "enum:"`` -- no values, so ``_check_one`` tests membership of the empty set and EVERY non-empty value fails QA for the life of the job. * ``format: "regex:"`` -- empty pattern, so ``re.fullmatch("", v)`` matches @@ -247,7 +260,7 @@ class for the two formats that take an ARGUMENT, and the one the first for kspec in _every_leaf(key_specs, array_specs): fmt = kspec.format lowered = fmt.casefold() - # `lowered` catches a bare case variant (`"Number"`). `kind_only` + # `lowered` catches a bare case variant (`"Number"`). The colon arm # catches the two formats that take an ARGUMENT, which the bare check # misses entirely: `_parse_format` matches the `enum:` / `regex:` # prefix case-SENSITIVELY, so `"Enum:paid,unpaid"` never becomes kind @@ -258,8 +271,15 @@ class for the two formats that take an ARGUMENT, and the one the first # for that key -- exactly the failure this function closes for bare # formats. # - # Splitting on the colon is safe for genuine free text: a hint like - # `"customer:id"` yields `"customer"`, which is not a known format. + # Scoped to `_ARGUMENT_FORMATS`, NOT all of `_KNOWN_FORMATS`. Comparing + # the pre-colon segment against every known name rejected ordinary + # prose: `"number: of items"`, `"date: of issue"`, `"currency: USD"` + # and `"string: free text"` all split to a known name and all raised, + # forcing authors to reword valid hints. None of those four formats + # takes an argument, so `"number:"` is not a spelling of anything and + # the hint is simply free text -- which this function must keep + # accepting, since free text is how a schema author gives the model a + # plain-language description. # # Rejected rather than auto-corrected, to match the bare case above: a # schema author who meant the format gets told the spelling, and one @@ -267,7 +287,7 @@ class for the two formats that take an ARGUMENT, and the one the first # would make case significant in one direction only. kind_only = lowered.split(":", 1)[0] if fmt not in _KNOWN_FORMATS and ( - lowered in _KNOWN_FORMATS or kind_only in _KNOWN_FORMATS + lowered in _KNOWN_FORMATS or (":" in fmt and kind_only in _ARGUMENT_FORMATS) ): raise SchemaError( f"'{kspec.path}' declares format {fmt!r}, which is not a known " diff --git a/unstract/agent-kv-schema/tests/test_compile.py b/unstract/agent-kv-schema/tests/test_compile.py index 471df824c7..26c54139ac 100644 --- a/unstract/agent-kv-schema/tests/test_compile.py +++ b/unstract/agent-kv-schema/tests/test_compile.py @@ -355,6 +355,35 @@ def test_free_text_containing_a_colon_is_not_mistaken_for_a_format(fmt): compile_schema(_one_key(fmt)) +@pytest.mark.parametrize( + "fmt", + [ + "number: of items", + "date: of issue", + "currency: USD", + "string: free text", + "Number: of items", + ], +) +def test_a_known_NAME_before_a_colon_is_still_free_text_when_it_takes_no_argument( + fmt, +): + """The over-rejection the first cut of the `Enum:` fix introduced. + + Comparing the pre-colon segment against every name in `_KNOWN_FORMATS` + rejected all of these -- ordinary prose a schema author is entitled to + write, since free text IS a supported way to describe a key. That is a + worse failure than the one being fixed: the `Enum:` bug silently disabled + validation for one key, this refused to compile the schema at all and made + authors reword valid hints. + + Only `enum` and `regex` take an argument (`_ARGUMENT_FORMATS`), so only + those can legitimately appear as `name:arg`. There is no such thing as + `number:`, which is exactly why these must stay free text. + """ + compile_schema(_one_key(fmt)) + + @pytest.mark.parametrize("fmt", ["number", "date", "currency", "string"]) def test_the_known_formats_still_compile(fmt): compile_schema(_one_key(fmt)) From e2093f84894d0a2fdadf81fab2ea3350190e3e2d Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Mon, 12 Oct 2026 10:24:51 +0530 Subject: [PATCH 20/54] UN-4232 [FIX] 0007 must not delete a schedule a self-hosted operator owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile, P1, and correct. 0007 deleted the two `PgPeriodicTask` rows by NAME. These rows are seeded inert, so deleting an inert one removes the accidental second owner 0004 added and nothing else -- but a self-hosted operator running the PG scheduler has every reason to have flipped `pg_owned: True` on them. That is exactly what `docs/agent-kv-api.md` §12 tells them to do, and there is no CronJob outside the cloud chart to replace it. Deleting by name alone would silently remove their only maintenance mechanism: stuck jobs stop being swept and hold their concurrency slots until the 6 h Redis TTL, and expired customer documents stay in the bucket indefinitely, with nothing reporting either. The delete is now scoped by name AND `pg_owned=False`. A row the operator has taken ownership of is left exactly as they set it, which is the correct reading of this migration's purpose: it removes a second owner that 0004 created by mistake, not any schedule carrying those two names. The no-double-fire argument is unaffected -- the collision requires a `pg_owned` row, and on cloud the chart is the owner and no row is ever flipped. Verified against a real Postgres: with one row flipped to `pg_owned=True` and the other left inert, applying 0007 leaves exactly the operator-owned row and removes exactly the inert one. Pinned so the scope cannot quietly widen back. Verification: agent_kv 303 passed. ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 --- .../0007_remove_agent_kv_periodic_tasks.py | 37 ++++++++++++++++--- .../tests/test_periodic_tasks_migration.py | 24 ++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py index bd2767aa7b..ed70b273d8 100644 --- a/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py +++ b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py @@ -39,10 +39,27 @@ (two trivial writes); an environment that already applied 0004 just deletes. Both land on the same state, which is the point. -The delete is scoped BY NAME and is idempotent: a fresh install's 0004 created -exactly these two rows, and an unscoped delete here would wipe the +The delete is scoped by name AND by ``pg_owned=False``, and is idempotent. + +The name scope is the obvious one: an unscoped delete would wipe the dashboard_metrics periodics this table is shared with. +The ``pg_owned=False`` scope is the important one. These rows are seeded inert, +so deleting an inert row removes the accidental second owner and nothing else. +But a self-hosted operator running the PG scheduler has every reason to flip +``pg_owned: True`` on them -- that is exactly what the docs tell them to do, and +there is no CronJob outside the cloud chart to replace it. Deleting by name +alone would silently remove a LIVE schedule from such a deployment: stuck jobs +would stop being swept (holding their concurrency slots until the 6 h Redis TTL) +and expired customer documents would stay in the bucket indefinitely, with +nothing reporting it. + +So a row the operator has taken ownership of is left alone. That is the correct +reading of this migration's purpose: it removes a second owner that 0004 added +by mistake, not any schedule that happens to carry these two names. The +no-double-fire argument is unaffected -- the collision needs a ``pg_owned`` +row, and on cloud the chart is the owner and no row is ever flipped. + **Self-hosted OSS operators who enable the Agent-KV API must schedule ``manage.py agent_kv_sweep`` and ``manage.py agent_kv_ttl_cleanup`` themselves** (cron, a systemd timer, or their own ``PgPeriodicTask`` rows with @@ -72,14 +89,22 @@ def remove_pg_periodic_tasks(apps, schema_editor): - """Delete the seeded rows. Idempotent: a fresh install matches nothing.""" + """Delete the seeded rows, but only while they are still inert. + + Idempotent: a fresh install matches nothing. + + `pg_owned=False` in the filter is load-bearing, not defensive. A + self-hosted operator who flipped these rows to `pg_owned: True` is running + the PG scheduler as their ONLY maintenance mechanism -- there is no CronJob + outside the cloud chart -- so deleting them by name would strand jobs and + retain expired documents silently. Their row is left exactly as they set it. + """ # snake_case, not the usual `PgPeriodicTask = apps.get_model(...)` Django # idiom: it is a local variable, and sonar's S117 reads the CamelCase name # as a naming violation. The historical-model object is the same either way. periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") - periodic_task.objects.filter( - name__in=[spec["name"] for spec in PG_PERIODIC_TASKS] - ).delete() + names = [spec["name"] for spec in PG_PERIODIC_TASKS] + periodic_task.objects.filter(name__in=names, pg_owned=False).delete() def restore_pg_periodic_tasks(apps, schema_editor): diff --git a/backend/agent_kv/tests/test_periodic_tasks_migration.py b/backend/agent_kv/tests/test_periodic_tasks_migration.py index 33b9d918b3..c2460c045f 100644 --- a/backend/agent_kv/tests/test_periodic_tasks_migration.py +++ b/backend/agent_kv/tests/test_periodic_tasks_migration.py @@ -144,6 +144,30 @@ def test_the_migration_is_reversible(): assert op.reverse_code is not migrations.RunPython.noop +def test_the_removal_spares_a_schedule_the_operator_has_taken_OWNERSHIP_of(): + """`pg_owned=True` means a self-hosted operator is relying on this row. + + These rows are seeded inert, so deleting an inert one removes the + accidental second owner and nothing else. But a self-hosted operator + running the PG scheduler has every reason to flip `pg_owned: True` -- the + docs tell them to, and there is no CronJob outside the cloud chart to + replace it. Deleting by NAME alone would silently remove their only + maintenance mechanism: stuck jobs stop being swept and hold their + concurrency slots until the 6 h Redis TTL, and expired customer documents + stay in the bucket indefinitely, with nothing reporting either. + + Verified end to end against a real database as well (flip one row to + `pg_owned=True`, migrate, and exactly that row survives); this pins the + predicate so the scope cannot quietly widen back to name-only. + """ + source = inspect.getsource(MIGRATION.remove_pg_periodic_tasks) + + assert "pg_owned=False" in source, ( + "the delete must spare rows an operator has taken ownership of; " + "name-only scoping strands a self-hosted deployment's maintenance" + ) + + def test_removal_is_idempotent_and_targets_only_the_two_seeded_names(): """A fresh install has no such rows, and re-running must still succeed. From 48771c5dfb721cc3e0305c1c4af59e9d441e3123 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Mon, 12 Oct 2026 10:36:32 +0530 Subject: [PATCH 21/54] UN-4232 [FIX] 0007's reverse must not reset ownership on a row it preserved Greptile, P1, and a real bug in the previous commit. Forward leaves a row alone when the operator has adopted it (`pg_owned=True`) -- so on a rollback that row is still there, and the reverse's `update_or_create` carried `"pg_owned": False` in its defaults. Rolling back 0007 therefore reset the operator's live schedule to inert, silently disabling a self-hosted deployment's only maintenance mechanism as a side effect of rolling back an unrelated release. Exactly the failure the forward narrowing was added to prevent, reintroduced on the way back. `get_or_create` instead: restore what forward REMOVED, never normalize what it deliberately preserved. New rows are still created inert. Verified against a real Postgres: adopt `agent_kv_sweep` (`pg_owned=True`), leave `agent_kv_ttl_cleanup` inert, migrate forward -> the adopted row survives and the inert one is deleted; roll back -> the adopted row is STILL `pg_owned=True` and the sibling returns inert. Pinned, and mutation-checked by restoring `update_or_create`. The companion finding -- that leaving `pg_owned=True` rows alone could preserve a double-owner on a CLOUD deployment, where the chart's CronJobs are the owner -- is answered in the migration docstring rather than by code, because a migration cannot tell a cloud deployment from a self-hosted one and `pg_owned=True` means opposite things in the two. Inert-only is still right: nothing automated ever sets that flag (the per-schedule ramp control is explicitly not built -- see `pg_queue/models.py`), so on cloud these rows are inert as seeded and 0007 removes all of them. Reaching the cloud case takes a deliberate manual flip on a deployment whose chart already runs the CronJobs, and silently deleting an operator's explicit configuration during an unrelated migration is not the remedy for that -- it is a one-line query to detect and belongs in the cloud runbook. The alternative trade, deleting by name regardless of ownership, strands every self-hosted deployment that followed the documented instructions. Verification: agent_kv 304 passed. ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 --- .../0007_remove_agent_kv_periodic_tasks.py | 43 +++++++++++++++---- .../tests/test_periodic_tasks_migration.py | 30 +++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py index ed70b273d8..59ac754189 100644 --- a/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py +++ b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py @@ -56,9 +56,28 @@ So a row the operator has taken ownership of is left alone. That is the correct reading of this migration's purpose: it removes a second owner that 0004 added -by mistake, not any schedule that happens to carry these two names. The -no-double-fire argument is unaffected -- the collision needs a ``pg_owned`` -row, and on cloud the chart is the owner and no row is ever flipped. +by mistake, not any schedule that happens to carry these two names. + +**The limit of that, stated plainly: a migration cannot tell a cloud +deployment from a self-hosted one, and ``pg_owned=True`` means opposite things +in the two.** Self-hosted, it is the operator's only maintenance mechanism and +must survive. On cloud, where the chart's CronJobs are the owner, it would be a +second owner and the collision this migration exists to remove. + +The reason inert-only is still right: nothing automated ever sets +``pg_owned=True``. The per-schedule ramp control that would is explicitly not +built yet (``pg_queue/models.py`` -- "that's the ramp control, which does not +exist yet"), so on cloud these rows are inert as seeded and this migration +removes all of them. Reaching the cloud double-fire case takes a deliberate +manual flip on a deployment whose chart already runs the CronJobs -- and +silently deleting an operator's explicit configuration during an unrelated +migration is not the remedy for that. It is visible in +``SELECT name, pg_owned FROM pg_periodic_schedule WHERE name LIKE 'agent_kv%'`` +and belongs in the cloud runbook, not in a guess made here. + +Choosing the other way round -- delete by name regardless of ownership -- trades +a manual-only cloud misconfiguration for silently stranding every self-hosted +deployment that followed the documented instructions. That is the worse trade. **Self-hosted OSS operators who enable the Agent-KV API must schedule ``manage.py agent_kv_sweep`` and ``manage.py agent_kv_ttl_cleanup`` @@ -108,14 +127,22 @@ def remove_pg_periodic_tasks(apps, schema_editor): def restore_pg_periodic_tasks(apps, schema_editor): - """Reverse only. Restores 0004's rows, still inert (`pg_owned: False`). - - A reverse that restored them ENABLED would turn a rollback into the - double-fire this migration exists to prevent. + """Reverse only. Re-create 0004's rows if they are MISSING -- nothing more. + + `get_or_create`, deliberately not `update_or_create`. Forward leaves a row + alone when the operator has adopted it (`pg_owned=True`), so on a rollback + that row is still there -- and an `update_or_create` whose defaults carry + `pg_owned: False` would reset it, silently disabling the self-hosted + operator's only maintenance mechanism as a side effect of rolling back an + unrelated release. The reverse must restore what forward removed, not + normalize what it preserved. + + New rows are created inert. A reverse that restored them ENABLED would turn + a rollback into the double-fire this migration exists to prevent. """ periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") for spec in PG_PERIODIC_TASKS: - periodic_task.objects.update_or_create( + periodic_task.objects.get_or_create( name=spec["name"], defaults={ "task_name": spec["task_name"], diff --git a/backend/agent_kv/tests/test_periodic_tasks_migration.py b/backend/agent_kv/tests/test_periodic_tasks_migration.py index c2460c045f..eac38e608a 100644 --- a/backend/agent_kv/tests/test_periodic_tasks_migration.py +++ b/backend/agent_kv/tests/test_periodic_tasks_migration.py @@ -217,6 +217,36 @@ def test_cron_strings_are_five_field_and_parseable(name): assert len(fields) == 5, _specs()[name]["cron_string"] +def test_the_reverse_does_not_overwrite_a_row_it_finds(): + """`get_or_create`, never `update_or_create`. + + Forward leaves a row alone when the operator has adopted it + (`pg_owned=True`), so on a rollback that row is still present. An + `update_or_create` whose defaults carry `pg_owned: False` would reset it -- + silently disabling a self-hosted operator's only maintenance mechanism as a + side effect of rolling back an unrelated release. + + The reverse's job is to restore what forward REMOVED, not to normalize what + it deliberately preserved. + + Verified end to end against a real database too: adopt one row, migrate + forward (it survives), roll back, and it is still `pg_owned=True` while the + deleted sibling comes back inert. + """ + # The CALL, not the prose: the docstring names `update_or_create` to + # explain why it is not used, so a bare substring check on the source + # fails on its own explanation. + source = inspect.getsource(MIGRATION.restore_pg_periodic_tasks) + + assert ".objects.get_or_create(" in source, ( + "the reverse must not overwrite an existing row's ownership" + ) + assert ".objects.update_or_create(" not in source, ( + "`update_or_create` resets `pg_owned` on a row forward deliberately " + "preserved, disabling a self-hosted operator's maintenance on rollback" + ) + + def test_the_restored_rows_are_inert(): """`pg_owned: False` on the reverse path too. From 534b2108917b957cd8f124273c6ed638fdc68a01 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Mon, 12 Oct 2026 10:47:06 +0530 Subject: [PATCH 22/54] UN-4232 [DOCS] 0007's diagnostic query named the wrong table Greptile, P2, and the query simply did not run. The rows live in `pg_periodic_task` (what `PgPeriodicTask` maps to, and what 0004 seeds). I wrote `pg_periodic_schedule`, which is the separate `PgPeriodicSchedule` model -- it carries a `pg_owned` column too, which is what made the mistake plausible, but it has no `name` at all (it keys on `pipeline_id`/`workflow_id`). An operator following the diagnostic got `ERROR: column "name" does not exist` rather than an answer about whether either schedule was live. Verified both ways against a real database: the query as written errors, the corrected one returns the two rows and their `pg_owned` state. Noted the distinction inline, since two models with a `pg_owned` column and similar names is exactly the confusion that produced this. Co-Authored-By: Claude Opus 5 --- .../migrations/0007_remove_agent_kv_periodic_tasks.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py index 59ac754189..8879cad572 100644 --- a/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py +++ b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py @@ -72,7 +72,13 @@ manual flip on a deployment whose chart already runs the CronJobs -- and silently deleting an operator's explicit configuration during an unrelated migration is not the remedy for that. It is visible in -``SELECT name, pg_owned FROM pg_periodic_schedule WHERE name LIKE 'agent_kv%'`` +``SELECT name, pg_owned FROM pg_periodic_task WHERE name LIKE 'agent_kv%'`` + +(``pg_periodic_task`` -- the table ``PgPeriodicTask`` maps to, which is what +0004 seeded. NOT ``pg_periodic_schedule``: that is the separate +``PgPeriodicSchedule`` model, which also carries a ``pg_owned`` column but has +no ``name`` at all -- it keys on ``pipeline_id``/``workflow_id`` -- so the +query named there failed outright with "column name does not exist".) and belongs in the cloud runbook, not in a guess made here. Choosing the other way round -- delete by name regardless of ownership -- trades From 785cee5d61cccd048366cc3d1bce4fccdaa8752d Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Mon, 12 Oct 2026 14:35:42 +0530 Subject: [PATCH 23/54] UN-4232 [FIX] A cancel that lost the race no longer destroys a paid-for result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile, and correct. `api_binding` catches `JobCancelled` on its FINAL stage report and returns success with the full output -- which makes the executor honest but cannot make the result reachable, and my comment there claimed otherwise ("returning the result, which is already paid for"). It was not returned to anyone. The window: the engine flushes OCR and LLM usage BEFORE the executor's final stage report, so a cancel colliding with that report lands after the customer has been billed. `FinalizeView._complete` then wrote the result, `mark_terminal` correctly declined to restatus a terminal row, and the `else` branch DELETED the file. `GET result` returned `{"success": false, "status": "cancelled"}` with no indication that a fully-computed, already-billed answer had existed and been destroyed. Money taken, nothing delivered, one INFO line. Fixed on the read-and-store side, where it belongs: * New `AgentKVJob.attach_result_to_cancelled` attaches `result_ref` and `usage_summary` to a CANCELLED row and CANNOT touch `status` or `completed_at` -- the cancel stays won and stays visible. Deliberately not folded into `mark_terminal`, whose whole job is guarding the status transition. Scoped to CANCELLED only: grafting a late callback's result onto a COMPLETED or FAILED row would overwrite a real outcome or invent one. `result_ref=""` in the filter is the at-least-once idempotency guard, so a second callback reports False and its caller deletes its own file rather than orphaning it. * `FinalizeView._complete` tries the salvage before deleting, so the orphan cleanup still runs for every case that is genuinely an orphan. * `result_payload` serves a cancelled job's result when it has one: `success: true`, `status: "cancelled"`. Branch on `success` for "is there data", on `status` for "was it cancelled" -- neither reading is misled. The ordinary cancel (no `result_ref`) is byte-identical to before. Already correct and verified rather than changed: the result view's expiry gate covers all statuses, so a salvaged result ages out with `AGENT_KV_RESULT_TTL_DAYS` and degrades back to the plain cancelled shape; and TTL cleanup selects `status__in=TERMINAL` with `result_ref__gt=""`, so it reaps these rather than leaking them. Why keep the result rather than just stop overclaiming: the usage is flushed and unrecoverable by the time this path is reachable, so the customer pays either way. Given that, discarding the answer is strictly the worse half of the trade. The cancel is still honoured -- it keeps the status -- it just no longer costs the caller the thing they were charged for. Tests: 8 DB-backed cases in `test_adapter_scoping.py` (attach, status/ completed_at untouched, COMPLETED and FAILED left alone, second-callback idempotency, blank ref, cross-org refusal, both read shapes) plus `test_finalize_guard_lost_to_a_cancel_salvages_the_paid_for_result` driving the real view. Mutation-checked by restoring the unconditional delete. Two existing tests needed their mocks corrected, same trap as elsewhere on this branch: `test_finalize_success_guard_loss_deletes_orphaned_result` and `test_finalize_duplicate_success_race_loser_deletes_only_its_own_result` patch `AgentKVJob.objects`, so the new `.update()` returned a truthy Mock and the salvage appeared to win for a RUNNING/COMPLETED row -- silently cancelling the orphan delete those tests exist to assert. `attach_result_to_cancelled` is now patched to False in both, which is what the real filter returns for those rows, with a comment saying so. `docs/agent-kv-api.md` §7.3 documents the new shape as an explicit exception. Verification: agent_kv 313 passed; `makemigrations --check` clean (classmethod only, no schema change); ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views_result.py | 29 +++- backend/agent_kv/internal_views.py | 23 ++- backend/agent_kv/models.py | 46 +++++ .../agent_kv/tests/test_adapter_scoping.py | 163 ++++++++++++++++++ backend/agent_kv/tests/test_internal_views.py | 62 ++++++- docs/agent-kv-api.md | 17 +- 6 files changed, 335 insertions(+), 5 deletions(-) diff --git a/backend/agent_kv/execution_views_result.py b/backend/agent_kv/execution_views_result.py index 5ec465a604..2233dfb720 100644 --- a/backend/agent_kv/execution_views_result.py +++ b/backend/agent_kv/execution_views_result.py @@ -25,7 +25,10 @@ def result_payload(job) -> dict: - COMPLETED: ``{success, status, extractors, usage_summary}`` -- the stored engine result namespaced per extractor (spec §7.3), not the bare blob. - FAILED: ``{"success": False, "status": "failed", "error": }``. - - CANCELLED: ``{"success": False, "status": "cancelled"}``. + - CANCELLED: ``{"success": False, "status": "cancelled"}`` -- unless the + cancel lost a race to a completed extraction, in which case the paid-for + result is served with ``success: True`` and ``status: "cancelled"`` (see + ``AgentKVJob.attach_result_to_cancelled``). Callers are responsible for confirming ``job.status`` is terminal (and, for COMPLETED, that ``result_ref`` is non-blank and unexpired) before @@ -60,4 +63,28 @@ def result_payload(job) -> dict: "status": "failed", "error": job.error or "Job failed", } + # A cancel that lost the race to a completed extraction keeps its result + # (`AgentKVJob.attach_result_to_cancelled`): the engine had already spent + # and flushed the OCR/LLM usage, so the customer is billed either way, and + # discarding the answer was the strictly worse half of that trade. + # + # `status` stays `cancelled` -- the cancel WAS honoured and a client must + # still be able to see that -- but `success` is True and the result is + # served, because there is a real result and it was paid for. A client that + # branches only on `success` gets the data; one that branches on `status` + # sees the cancellation. Neither is misled. + # + # `result_ref` is blank for the ordinary cancel (nothing ever ran, or the + # run was stopped mid-flight), which keeps that path byte-identical. + if job.result_ref: + usage = job.usage_summary or {} + return { + "success": True, + "status": "cancelled", + "extractors": {job.extractor: read_result(job.result_ref)}, + "usage_summary": { + "total": usage, + "by_extractor": {job.extractor: usage}, + }, + } return {"success": False, "status": "cancelled"} diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index de4583f5ff..29b1681591 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -198,6 +198,25 @@ class FinalizeView(APIView): def _complete(job, job_id, org_id: str, body) -> bool: """Persist the result and terminalize as COMPLETED. True if the guard won. + **A cancel that lost the race does not forfeit the result.** The window + is real: the executor can finish extraction -- spending OCR and LLM -- + and only then try its final stage report, which gets a ``noop`` because + a cancel terminalized the row in the meantime. The executor returns + success with the full output, this method writes it, and the guard below + correctly declines to change a terminal row's status. Deleting the file + at that point billed the customer for an answer and then destroyed it: + usage is flushed by the engine before any of this runs, so the money is + already spent either way, and `GET result` reported + ``{"success": false, "status": "cancelled"}`` with no indication that a + paid-for result had existed and been discarded. + + So a CANCELLED row keeps the result instead: ``result_ref`` and + ``usage_summary`` are attached WITHOUT touching ``status`` or + ``completed_at``, the cancel stays honoured, and ``result_payload`` + serves the data alongside ``status: "cancelled"``. The terminal guard + still owns the status transition -- this only adds a pointer to work + that was already paid for. + ``write_result`` unavoidably runs *before* the ``mark_terminal`` guard (the result has to exist to get a ``result_ref`` to pass in), so a guard-loss right after the write -- a concurrent cancel or duplicate @@ -216,7 +235,9 @@ def _complete(job, job_id, org_id: str, body) -> bool: ) if finalized: job.status = JobStatus.COMPLETED - else: + elif not AgentKVJob.attach_result_to_cancelled( + job_id, org_id, result_ref=result_ref, usage_summary=body.get("usage_summary") + ): delete_result_file(result_ref) return finalized diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index 45df84d8dd..f2377acb1a 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -163,6 +163,52 @@ class Meta: models.Index(fields=["cleanup_failed_at", "expires_at"]), ] + @classmethod + def attach_result_to_cancelled( + cls, job_id, organization_id, *, result_ref, usage_summary=None + ) -> bool: + """Keep a paid-for result on a job that was CANCELLED mid-flight. + + The race this exists for: extraction completes -- OCR and LLM already + spent and flushed by the engine -- and only then does the executor's + final stage report land, by which time a cancel has terminalized the + row. The executor returns success with the full output, `FinalizeView` + writes it, and `mark_terminal` correctly declines to restatus a + terminal row. Before this, the written result was then DELETED: the + customer was billed for an answer and the answer was destroyed, with + `GET result` reporting only `{"success": false, "status": "cancelled"}`. + + Deliberately NOT part of `mark_terminal`, and deliberately unable to + change `status` or `completed_at`: the cancel stays won and stays + visible. This adds a pointer to work that was already paid for, nothing + more. `result_payload` then serves the data alongside + `status: "cancelled"`. + + Scoped to CANCELLED only. A COMPLETED or FAILED row already has its own + outcome and must not have a late callback's result grafted onto it -- + which is why this is not simply "attach to any terminal row". + + `result_ref=""` is the idempotency guard: a second at-least-once + callback finds the ref already set, matches zero rows, and its own + freshly written file is deleted by the caller rather than orphaned. + + Returns True when this call attached the result (and the caller must + therefore keep the file), False when it did not (caller deletes it). + """ + if not result_ref: + return False + fields = {"result_ref": result_ref} + if usage_summary is not None: + fields["usage_summary"] = usage_summary + return bool( + cls.objects.filter( + id=job_id, + organization_id=organization_id, + status=JobStatus.CANCELLED, + result_ref="", + ).update(**fields) + ) + @classmethod def mark_terminal( cls, diff --git a/backend/agent_kv/tests/test_adapter_scoping.py b/backend/agent_kv/tests/test_adapter_scoping.py index b5a6f59c44..05ca657f56 100644 --- a/backend/agent_kv/tests/test_adapter_scoping.py +++ b/backend/agent_kv/tests/test_adapter_scoping.py @@ -431,3 +431,166 @@ def test_a_stale_in_memory_row_cannot_override_the_database(self): job.dispatched_at = None assert _never_dispatched(job) is False + + +class TestACancelThatLostTheRaceKeepsItsResult(TestCase): + """A cancel landing after extraction finished must not destroy the answer. + + The window is real and the money is already gone when it opens: the engine + flushes OCR and LLM usage before the executor's final stage report, so by + the time a cancel can collide with that report the customer has been + billed. `FinalizeView` then wrote the result, `mark_terminal` correctly + declined to restatus a terminal row, and the file was DELETED -- so + `GET result` returned `{"success": false, "status": "cancelled"}` with no + sign that a paid-for result had existed. + + DB-backed because the whole mechanism is a guarded UPDATE racing another + guarded UPDATE; a mock would just agree with whichever one I wrote. + """ + + def setUp(self): + self.org = Organization.objects.create( + display_name="Acme", organization_id="org_acme", name="acme" + ) + UserContext.set_organization_identifier(None) + + def _job(self, **kwargs): + from agent_kv.models import AgentKVJob, JobExtractor + + job = AgentKVJob( + organization_id=self.org.id, extractor=JobExtractor.TABLE, **kwargs + ) + job.save() + return job + + def test_the_result_is_attached_to_a_cancelled_job(self): + from agent_kv.models import AgentKVJob, JobStatus + + job = self._job(status=JobStatus.CANCELLED) + + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/1.json", usage_summary={"pages": 3} + ) + job.refresh_from_db() + + assert attached is True + assert job.result_ref == "r/1.json" + assert job.usage_summary == {"pages": 3} + + def test_it_never_changes_status_or_completed_at(self): + """The cancel stays won, and stays visible. + + If this ever starts flipping `status`, a cancelled job would read as + completed and the guard `mark_terminal` exists to enforce would have + been bypassed by the back door. + """ + from agent_kv.models import AgentKVJob, JobStatus + from django.utils import timezone + + stamped = timezone.now() + job = self._job(status=JobStatus.CANCELLED, completed_at=stamped) + + AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/2.json" + ) + job.refresh_from_db() + + assert job.status == JobStatus.CANCELLED + assert job.completed_at == stamped + + def test_a_completed_or_failed_job_is_left_alone(self): + """Scoped to CANCELLED on purpose. + + A COMPLETED or FAILED row already has its own outcome; grafting a late + callback's result onto it would overwrite a real one (COMPLETED) or + invent one for a run that failed. + """ + from agent_kv.models import AgentKVJob, JobStatus + + for status in (JobStatus.COMPLETED, JobStatus.FAILED): + job = self._job(status=status) + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/nope.json" + ) + job.refresh_from_db() + assert attached is False, status + assert job.result_ref == "", status + + def test_a_second_callback_does_not_overwrite_the_first(self): + """At-least-once delivery means this runs twice. + + The second call must report False so its caller deletes its own freshly + written file rather than orphaning it -- `result_ref=""` in the filter + is what makes that happen. + """ + from agent_kv.models import AgentKVJob, JobStatus + + job = self._job(status=JobStatus.CANCELLED) + + first = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/first.json" + ) + second = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/second.json" + ) + job.refresh_from_db() + + assert (first, second) == (True, False) + assert job.result_ref == "r/first.json" + + def test_a_blank_ref_is_refused_so_the_caller_keeps_its_delete(self): + from agent_kv.models import AgentKVJob, JobStatus + + job = self._job(status=JobStatus.CANCELLED) + + assert ( + AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref="") + is False + ) + + def test_another_org_cannot_attach_a_result(self): + from agent_kv.models import AgentKVJob, JobStatus + + other = Organization.objects.create( + display_name="Other", organization_id="org_other", name="other" + ) + job = self._job(status=JobStatus.CANCELLED) + + assert ( + AgentKVJob.attach_result_to_cancelled( + job.id, other.id, result_ref="r/x.json" + ) + is False + ) + + def test_the_result_payload_serves_it_while_still_reporting_cancelled(self): + """The read half. `status` must stay `cancelled` -- the cancel WAS + honoured and a client has to be able to see that -- while `success` is + True because there is a real, paid-for result to hand over. + """ + from agent_kv.execution_views_result import result_payload + from agent_kv.models import JobStatus + from unittest import mock + + job = self._job(status=JobStatus.CANCELLED, result_ref="r/3.json") + + with mock.patch( + "agent_kv.execution_views_result.read_result", + return_value={"tables": [], "row_count": 0}, + ): + payload = result_payload(job) + + assert payload["status"] == "cancelled" + assert payload["success"] is True + assert payload["extractors"]["table"] == {"tables": [], "row_count": 0} + + def test_an_ordinary_cancel_is_byte_identical_to_before(self): + """The common path -- cancelled mid-run, nothing computed -- must not + change shape just because the salvage path exists. + """ + from agent_kv.execution_views_result import result_payload + from agent_kv.models import JobStatus + + job = self._job(status=JobStatus.CANCELLED) + + assert result_payload(job) == {"success": False, "status": "cancelled"} diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py index 20cba4c677..42d74760e3 100644 --- a/backend/agent_kv/tests/test_internal_views.py +++ b/backend/agent_kv/tests/test_internal_views.py @@ -412,13 +412,21 @@ def test_finalize_success_deletes_input_and_blanks_ref( # cancel raced in between the read and mark_terminal's guarded UPDATE) -- # the just-written result file has no ref anywhere pointing at it, so it's # cleaned up immediately rather than orphaned forever. +# `attach_result_to_cancelled` is patched to False here, which is what the real +# DB returns for these jobs: its filter is `status=CANCELLED, result_ref=""`, +# and the rows in these two cases are RUNNING and COMPLETED. Without the patch +# the mocked `AgentKVJob.objects` makes its `.update()` return a Mock, which is +# truthy, so the salvage would appear to win and the orphan delete these tests +# exist to assert would silently stop being called. The salvage path itself is +# covered DB-backed in `test_adapter_scoping.py`. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) @mock.patch.object(iv, "write_result") @mock.patch.object(AgentKVJob, "objects") def test_finalize_success_guard_loss_deletes_orphaned_result( - m_jobs, m_write, m_mark_terminal, m_delete_result, m_release + m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release ): job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") m_jobs.filter.return_value.first.return_value = job @@ -438,6 +446,52 @@ def test_finalize_success_guard_loss_deletes_orphaned_result( m_delete_result.assert_called_once_with("org/o/agent_kv/j/result.json") +# (7c-ii) the case (7c) above must NOT catch: the guard was lost to a CANCEL, +# and the run had already finished and been billed. Deleting the result there +# charged the customer for an answer and then destroyed it, with `GET result` +# reporting only `{"success": false, "status": "cancelled"}`. The result is +# salvaged onto the cancelled row instead. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=True) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_guard_lost_to_a_cancel_salvages_the_paid_for_result( + m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release +): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") + m_jobs.filter.return_value.first.return_value = job + m_write.return_value = "org/o/agent_kv/j/result.json" + + job_id = uuid.uuid4() + resp = iv.FinalizeView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "success": True, + "result": {"tables": []}, + "usage_summary": {"pages": 3}, + }, + ), + job_id=job_id, + ) + + assert resp.status_code == 200 + # Still not "finalized": the cancel won the status, and this must not + # pretend otherwise. + assert resp.data["finalized"] is False + m_salvage.assert_called_once() + kwargs = m_salvage.call_args.kwargs + assert kwargs["result_ref"] == "org/o/agent_kv/j/result.json" + assert kwargs["usage_summary"] == {"pages": 3} + assert not m_delete_result.called, ( + "the result was deleted after the customer had already been billed " + "for it; a cancelled job keeps a result it paid for" + ) + + # (7d) success finalize that WINS the guard never deletes the result it just # wrote -- only a guard-LOSS orphan triggers cleanup. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @@ -472,10 +526,14 @@ def test_finalize_success_guard_win_does_not_delete_result( # result.json path let the loser delete the winner's live result). @mock.patch.object(storage, "FileSystem") @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +# False for the same reason as the guard-loss case above: the row the loser +# finds is COMPLETED, not CANCELLED, so the real filter matches nothing and the +# loser must still delete the file it just wrote. +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", side_effect=[True, False]) @mock.patch.object(AgentKVJob, "objects") def test_finalize_duplicate_success_race_loser_deletes_only_its_own_result( - m_jobs, m_mark, m_release, m_fs + m_jobs, m_mark, m_salvage, m_release, m_fs ): # Two separate reads that both observed the job as still RUNNING (the race # window before either guarded UPDATE ran). diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 63defb6cb5..9558a42554 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -483,7 +483,22 @@ Re-readable until `expires_at` (D11), plus explicit `DELETE`. finalize call sent). - **`FAILED`**: `200` with `{"success": false, "status": "failed", "error": ""}`. -- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}`. +- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}` — the + ordinary case, where nothing ran or the run was stopped mid-flight. + - **Exception: a cancel that lost a race to a finished extraction keeps its + result.** The window is narrow but the money is already gone when it opens: + the engine flushes OCR and LLM usage *before* the executor's final stage + report, so a cancel colliding with that report arrives after the customer + has been billed. Such a job returns `200` with + `{"success": true, "status": "cancelled", "extractors": {...}, "usage_summary": {...}}` + — the full result, with `status` still `cancelled` because the cancel *was* + honoured. Branch on `success` for "is there data"; branch on `status` for + "was it cancelled". Previously the result was written and then deleted, so + the caller paid for an answer and received + `{"success": false, "status": "cancelled"}` with no indication it had ever + existed. The job is **not** reported as `completed` — the cancel still owns + the status — and the result rides the same `AGENT_KV_RESULT_TTL_DAYS` window + as any other, after which this degrades back to the ordinary shape. **Calculation result shape** (when `calculations` is supplied and `AGENT_KV_CALCULATIONS_ENABLED=true`): a `COMPLETED` result adds these fields to the From c0726bae3be38a59332d2cc7c8e6321f999c9e62 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Mon, 12 Oct 2026 14:52:44 +0530 Subject: [PATCH 24/54] UN-4232 [FIX] The salvage must not resurrect a result the caller DELETEd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile, P1, and a hole my own previous commit opened. `result_ref=""` cannot distinguish "cancelled, nothing stored yet" from "cancelled and explicitly purged": `JobDeleteView` deletes the files and blanks BOTH refs, which is exactly the row `attach_result_to_cancelled` was matching. A late finalize would therefore have written a new result and attached it to a job whose data the caller had asked to be destroyed, making it readable again. That is a deletion violation, and it is strictly worse than the billing problem the salvage exists to fix -- so the trade as written was a bad one. `input_ref__gt=""` closes it, and it is a sound discriminator rather than a heuristic: * `_complete` runs ONLY when the job read was non-terminal (`internal_views.py:289`), so the salvage path is by definition a LOSING finalize -- and `_drop_staged_input` fires only when `finalized` is True. A cancelled job therefore still has its staged input (it rides `AGENT_KV_RESULT_TTL_DAYS`; the docs already stated this). * `JobDeleteView` blanks both refs via `delete_job_files`. * TTL cleanup blanks both. So: input set -> salvage; input blank -> purged or swept -> refused. Residual, documented at the method rather than left to be discovered: TTL cleanup's filter is `Q(input_ref__gt="") | Q(result_ref__gt="")`, so it can select the row, read an empty `result_ref`, and blank the refs after this update has set one -- orphaning the new result file. A storage leak, the same class the `JobDeleteView` refresh already documents, bounded by one sweep, and strictly better than the alternative it replaces (destroying a paid-for result). Three DB-backed cases: a purged job is refused, a TTL-swept job is refused, a live cancelled job still salvages. Mutation-checked by removing the guard -- the purge and TTL cases both fail. The eight existing salvage tests now seed a live `input_ref`, with a comment saying why a blank one is the purge marker. Also Greptile P2, same review: §7.3 said a cancelled job can retain a finished extraction while the cancellation section still said such a result is "discarded on arrival". A consumer following the latter would never fetch a result they had paid for. The cancellation section now states the behaviour, names both cases where the result genuinely is gone (DELETE, TTL), and §7.3 cross-references the DELETE exception. Verification: agent_kv 316 passed; ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/models.py | 31 +++++++ .../agent_kv/tests/test_adapter_scoping.py | 93 +++++++++++++++++++ docs/agent-kv-api.md | 23 ++++- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index f2377acb1a..eaabd767f6 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -192,6 +192,33 @@ def attach_result_to_cancelled( callback finds the ref already set, matches zero rows, and its own freshly written file is deleted by the caller rather than orphaned. + **`input_ref__gt=""` is the purge guard, and it is the important one.** + `result_ref=""` alone cannot tell "cancelled, nothing stored yet" from + "cancelled and the caller explicitly DELETEd it" -- `JobDeleteView` + purges the files and blanks both refs, leaving exactly the row this + filter was matching. A late finalize would then have attached a newly + written result to a job whose data the caller had asked to be destroyed, + making it readable again. That is a deletion violation, not a + correctness nit, and it is strictly worse than the billing problem the + salvage exists to fix. + + `input_ref` discriminates them because a cancelled job KEEPS its staged + input (it rides `AGENT_KV_RESULT_TTL_DAYS`; only a winning finalize + drops it, via `_drop_staged_input`, which runs only when `finalized` is + True). So: + + * cancelled mid-flight, not deleted -> `input_ref` set -> salvage + * DELETEd by the caller -> `input_ref` blank -> refused + * swept by TTL cleanup -> `input_ref` blank -> refused + + Residual, stated rather than papered over: TTL cleanup can select this + row (its filter is `Q(input_ref__gt="") | Q(result_ref__gt="")`), read + an empty `result_ref`, and blank the refs after this update has set one + -- leaving the new result file orphaned in the bucket. A storage leak, + the same class the `JobDeleteView` refresh already documents, bounded by + the width of that one sweep, and strictly better than the alternative it + replaces, which was destroying a result the customer had paid for. + Returns True when this call attached the result (and the caller must therefore keep the file), False when it did not (caller deletes it). """ @@ -206,6 +233,10 @@ def attach_result_to_cancelled( organization_id=organization_id, status=JobStatus.CANCELLED, result_ref="", + # See the purge guard in the docstring: a blank `input_ref` + # means the caller DELETEd this job or TTL cleanup swept it. + # Re-attaching a result there resurrects purged data. + input_ref__gt="", ).update(**fields) ) diff --git a/backend/agent_kv/tests/test_adapter_scoping.py b/backend/agent_kv/tests/test_adapter_scoping.py index 05ca657f56..7a060c74eb 100644 --- a/backend/agent_kv/tests/test_adapter_scoping.py +++ b/backend/agent_kv/tests/test_adapter_scoping.py @@ -457,6 +457,10 @@ def setUp(self): def _job(self, **kwargs): from agent_kv.models import AgentKVJob, JobExtractor + # `input_ref` defaults to a live staged input: a cancelled job keeps + # its input until TTL, and a BLANK one is the purge marker the salvage + # must refuse (see `test_a_purged_job_does_not_get_its_result_back`). + kwargs.setdefault("input_ref", "org/o/agent_kv/j/input.pdf") job = AgentKVJob( organization_id=self.org.id, extractor=JobExtractor.TABLE, **kwargs ) @@ -594,3 +598,92 @@ def test_an_ordinary_cancel_is_byte_identical_to_before(self): job = self._job(status=JobStatus.CANCELLED) assert result_payload(job) == {"success": False, "status": "cancelled"} + + +class TestThePurgeGuardOnTheSalvage(TestCase): + """A result the caller DELETEd must not come back. + + `result_ref=""` alone cannot distinguish "cancelled, nothing stored yet" + from "cancelled and explicitly purged": `JobDeleteView` deletes the files + and blanks BOTH refs, which is exactly the row the salvage filter matched. + A late finalize would then re-attach a newly written result to a job whose + data the caller asked to have destroyed. + + `input_ref` is the discriminator: a cancelled job keeps its staged input + (only a WINNING finalize drops it, and the salvage path is by definition a + losing one), while DELETE and TTL cleanup both blank it. + """ + + def setUp(self): + self.org = Organization.objects.create( + display_name="Acme", organization_id="org_acme", name="acme" + ) + UserContext.set_organization_identifier(None) + + def _cancelled(self, **kwargs): + from agent_kv.models import AgentKVJob, JobExtractor, JobStatus + + job = AgentKVJob( + organization_id=self.org.id, + extractor=JobExtractor.TABLE, + status=JobStatus.CANCELLED, + **kwargs, + ) + job.save() + return job + + def test_a_purged_job_does_not_get_its_result_back(self): + """The deletion violation. Both refs blank = the caller purged it.""" + from agent_kv.models import AgentKVJob + + job = self._cancelled(input_ref="", result_ref="") + + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="org/o/agent_kv/j/result.json" + ) + job.refresh_from_db() + + assert attached is False, ( + "re-attached a result to a job the caller DELETEd; purged data " + "must not become readable again" + ) + assert job.result_ref == "" + + def test_a_live_cancelled_job_still_gets_its_result(self): + """The other half: the guard must not refuse the case it exists for.""" + from agent_kv.models import AgentKVJob + + job = self._cancelled(input_ref="org/o/agent_kv/j/input.pdf", result_ref="") + + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="org/o/agent_kv/j/result.json" + ) + job.refresh_from_db() + + assert attached is True + assert job.result_ref == "org/o/agent_kv/j/result.json" + + def test_a_ttl_swept_job_does_not_get_a_result_grafted_on(self): + """TTL cleanup blanks both refs, same shape as DELETE. + + Re-attaching there would put a result on a job past its retention + window, which `JobResultView`'s expiry gate would then 404 anyway -- + but the file would exist, unreferenced-but-reachable, after the + retention window the customer was promised. + """ + from agent_kv.models import AgentKVJob + from django.utils import timezone + from datetime import timedelta + + job = self._cancelled( + input_ref="", + result_ref="", + expires_at=timezone.now() - timedelta(days=1), + ) + + assert ( + AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="org/o/agent_kv/j/late.json" + ) + is False + ) diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 9558a42554..e8aad5be3d 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -498,7 +498,9 @@ Re-readable until `expires_at` (D11), plus explicit `DELETE`. `{"success": false, "status": "cancelled"}` with no indication it had ever existed. The job is **not** reported as `completed` — the cancel still owns the status — and the result rides the same `AGENT_KV_RESULT_TTL_DAYS` window - as any other, after which this degrades back to the ordinary shape. + as any other, after which this degrades back to the ordinary shape. It is + also refused outright if you called `DELETE` on the job: the purge wins, and + a late result is not resurrected. **Calculation result shape** (when `calculations` is supplied and `AGENT_KV_CALCULATIONS_ENABLED=true`): a `COMPLETED` result adds these fields to the @@ -649,8 +651,23 @@ which the internal endpoint answers as a no-op once the row is terminal. Both ex check at their engine's progress points — `kv` at every graph-node boundary, `table` at the engine's own progress points (rate-limited, so detection can lag by a few seconds) — and the job then terminates as `cancelled`. Work already done is still billed and -metered; work not yet started is not. A job that finishes before the cancel is noticed -simply has its result discarded on arrival. +metered; work not yet started is not. + +**A job that finishes before the cancel is noticed KEEPS its result.** This used +to say the result was "discarded on arrival", which was accurate and was the +problem: the engine flushes OCR and LLM usage before the executor's final stage +report, so the customer had already been billed for an answer that was then +written and deleted. Such a job stays `cancelled` — the cancel is honoured and +visible — but `GET /agent-kv/{job_id}/result/` serves the result with +`success: true` (see [§5](#7-get-result)). Fetch it; you have paid for it. + +Two cases where it genuinely is gone, both deliberate: + +- You called `DELETE /agent-kv/{job_id}/` — the purge wins, and a late result is + refused rather than resurrected. +- The job passed `AGENT_KV_RESULT_TTL_DAYS` and TTL cleanup swept it, after + which the endpoint reports the ordinary + `{"success": false, "status": "cancelled"}`. - Won the race (job was non-terminal): `200` `{"status": "cancelled"}`. - Already terminal: `409` `{"status": ""}`, lowercased (e.g. From ad218c8f0228db8b567d92da1712782013567e76 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Mon, 12 Oct 2026 15:05:05 +0530 Subject: [PATCH 25/54] UN-4232 [REVERT] Back out the cancelled-result salvage; document the gap instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts 785cee5d6 and c0726bae3. Greptile was right twice, and the second time it pointed at an invariant this code already documents. `JobStatusView.delete` opens with: # Cancel BEFORE deleting files: ... a finalize call that still lands late # loses the terminal-state guard and, on the success path, CLEANS UP ITS # OWN NOW-ORPHANED WRITE (FinalizeView, storage.delete_result_file). DELETE's guarantee that a purged job cannot be resurrected **depends on** the late finalize deleting its own result file. The salvage removed exactly that cleanup for CANCELLED rows, so it broke the invariant DELETE relies on. The `input_ref__gt=""` purge guard narrowed it but could not close it: at the moment the late finalize attaches, DELETE has not yet blanked `input_ref`, so a purged job could still end up serving a result -- and TTL cleanup could blank a ref it never deleted, orphaning the file. Closing it properly needs an explicit "files purged" marker that DELETE, TTL cleanup and finalize all agree on, instead of inferring purge state from blank refs. That is a migration plus three call sites on a path with three concurrent writers doing read-modify-write. Not something to bolt onto an Agent-KV carve-out PR, and the same judgement applied to `hitl/cronjob.yaml` earlier in this review: a guessed-at fix to a delicate invariant is worse than a documented status quo. So the behaviour returns to what it was -- the result is discarded and the customer is billed -- and the docs now SAY so, which is the part that was genuinely wrong before. §8 carries it as a "Known gap" with the billing consequence, why keeping the result was tried and reverted, and what a real fix requires. The cloud side's log line keeps its corrected wording for the same reason: it no longer claims the result is delivered. What this does NOT revert: nothing. Both commits are backed out whole; the only net change versus 2f177c8ad is the §8 documentation. Verification: agent_kv 304 passed (back to the pre-salvage count); ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views_result.py | 29 +- backend/agent_kv/internal_views.py | 23 +- backend/agent_kv/models.py | 77 ------ .../agent_kv/tests/test_adapter_scoping.py | 256 ------------------ backend/agent_kv/tests/test_internal_views.py | 62 +---- docs/agent-kv-api.md | 52 ++-- 6 files changed, 23 insertions(+), 476 deletions(-) diff --git a/backend/agent_kv/execution_views_result.py b/backend/agent_kv/execution_views_result.py index 2233dfb720..5ec465a604 100644 --- a/backend/agent_kv/execution_views_result.py +++ b/backend/agent_kv/execution_views_result.py @@ -25,10 +25,7 @@ def result_payload(job) -> dict: - COMPLETED: ``{success, status, extractors, usage_summary}`` -- the stored engine result namespaced per extractor (spec §7.3), not the bare blob. - FAILED: ``{"success": False, "status": "failed", "error": }``. - - CANCELLED: ``{"success": False, "status": "cancelled"}`` -- unless the - cancel lost a race to a completed extraction, in which case the paid-for - result is served with ``success: True`` and ``status: "cancelled"`` (see - ``AgentKVJob.attach_result_to_cancelled``). + - CANCELLED: ``{"success": False, "status": "cancelled"}``. Callers are responsible for confirming ``job.status`` is terminal (and, for COMPLETED, that ``result_ref`` is non-blank and unexpired) before @@ -63,28 +60,4 @@ def result_payload(job) -> dict: "status": "failed", "error": job.error or "Job failed", } - # A cancel that lost the race to a completed extraction keeps its result - # (`AgentKVJob.attach_result_to_cancelled`): the engine had already spent - # and flushed the OCR/LLM usage, so the customer is billed either way, and - # discarding the answer was the strictly worse half of that trade. - # - # `status` stays `cancelled` -- the cancel WAS honoured and a client must - # still be able to see that -- but `success` is True and the result is - # served, because there is a real result and it was paid for. A client that - # branches only on `success` gets the data; one that branches on `status` - # sees the cancellation. Neither is misled. - # - # `result_ref` is blank for the ordinary cancel (nothing ever ran, or the - # run was stopped mid-flight), which keeps that path byte-identical. - if job.result_ref: - usage = job.usage_summary or {} - return { - "success": True, - "status": "cancelled", - "extractors": {job.extractor: read_result(job.result_ref)}, - "usage_summary": { - "total": usage, - "by_extractor": {job.extractor: usage}, - }, - } return {"success": False, "status": "cancelled"} diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index 29b1681591..de4583f5ff 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -198,25 +198,6 @@ class FinalizeView(APIView): def _complete(job, job_id, org_id: str, body) -> bool: """Persist the result and terminalize as COMPLETED. True if the guard won. - **A cancel that lost the race does not forfeit the result.** The window - is real: the executor can finish extraction -- spending OCR and LLM -- - and only then try its final stage report, which gets a ``noop`` because - a cancel terminalized the row in the meantime. The executor returns - success with the full output, this method writes it, and the guard below - correctly declines to change a terminal row's status. Deleting the file - at that point billed the customer for an answer and then destroyed it: - usage is flushed by the engine before any of this runs, so the money is - already spent either way, and `GET result` reported - ``{"success": false, "status": "cancelled"}`` with no indication that a - paid-for result had existed and been discarded. - - So a CANCELLED row keeps the result instead: ``result_ref`` and - ``usage_summary`` are attached WITHOUT touching ``status`` or - ``completed_at``, the cancel stays honoured, and ``result_payload`` - serves the data alongside ``status: "cancelled"``. The terminal guard - still owns the status transition -- this only adds a pointer to work - that was already paid for. - ``write_result`` unavoidably runs *before* the ``mark_terminal`` guard (the result has to exist to get a ``result_ref`` to pass in), so a guard-loss right after the write -- a concurrent cancel or duplicate @@ -235,9 +216,7 @@ def _complete(job, job_id, org_id: str, body) -> bool: ) if finalized: job.status = JobStatus.COMPLETED - elif not AgentKVJob.attach_result_to_cancelled( - job_id, org_id, result_ref=result_ref, usage_summary=body.get("usage_summary") - ): + else: delete_result_file(result_ref) return finalized diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index eaabd767f6..45df84d8dd 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -163,83 +163,6 @@ class Meta: models.Index(fields=["cleanup_failed_at", "expires_at"]), ] - @classmethod - def attach_result_to_cancelled( - cls, job_id, organization_id, *, result_ref, usage_summary=None - ) -> bool: - """Keep a paid-for result on a job that was CANCELLED mid-flight. - - The race this exists for: extraction completes -- OCR and LLM already - spent and flushed by the engine -- and only then does the executor's - final stage report land, by which time a cancel has terminalized the - row. The executor returns success with the full output, `FinalizeView` - writes it, and `mark_terminal` correctly declines to restatus a - terminal row. Before this, the written result was then DELETED: the - customer was billed for an answer and the answer was destroyed, with - `GET result` reporting only `{"success": false, "status": "cancelled"}`. - - Deliberately NOT part of `mark_terminal`, and deliberately unable to - change `status` or `completed_at`: the cancel stays won and stays - visible. This adds a pointer to work that was already paid for, nothing - more. `result_payload` then serves the data alongside - `status: "cancelled"`. - - Scoped to CANCELLED only. A COMPLETED or FAILED row already has its own - outcome and must not have a late callback's result grafted onto it -- - which is why this is not simply "attach to any terminal row". - - `result_ref=""` is the idempotency guard: a second at-least-once - callback finds the ref already set, matches zero rows, and its own - freshly written file is deleted by the caller rather than orphaned. - - **`input_ref__gt=""` is the purge guard, and it is the important one.** - `result_ref=""` alone cannot tell "cancelled, nothing stored yet" from - "cancelled and the caller explicitly DELETEd it" -- `JobDeleteView` - purges the files and blanks both refs, leaving exactly the row this - filter was matching. A late finalize would then have attached a newly - written result to a job whose data the caller had asked to be destroyed, - making it readable again. That is a deletion violation, not a - correctness nit, and it is strictly worse than the billing problem the - salvage exists to fix. - - `input_ref` discriminates them because a cancelled job KEEPS its staged - input (it rides `AGENT_KV_RESULT_TTL_DAYS`; only a winning finalize - drops it, via `_drop_staged_input`, which runs only when `finalized` is - True). So: - - * cancelled mid-flight, not deleted -> `input_ref` set -> salvage - * DELETEd by the caller -> `input_ref` blank -> refused - * swept by TTL cleanup -> `input_ref` blank -> refused - - Residual, stated rather than papered over: TTL cleanup can select this - row (its filter is `Q(input_ref__gt="") | Q(result_ref__gt="")`), read - an empty `result_ref`, and blank the refs after this update has set one - -- leaving the new result file orphaned in the bucket. A storage leak, - the same class the `JobDeleteView` refresh already documents, bounded by - the width of that one sweep, and strictly better than the alternative it - replaces, which was destroying a result the customer had paid for. - - Returns True when this call attached the result (and the caller must - therefore keep the file), False when it did not (caller deletes it). - """ - if not result_ref: - return False - fields = {"result_ref": result_ref} - if usage_summary is not None: - fields["usage_summary"] = usage_summary - return bool( - cls.objects.filter( - id=job_id, - organization_id=organization_id, - status=JobStatus.CANCELLED, - result_ref="", - # See the purge guard in the docstring: a blank `input_ref` - # means the caller DELETEd this job or TTL cleanup swept it. - # Re-attaching a result there resurrects purged data. - input_ref__gt="", - ).update(**fields) - ) - @classmethod def mark_terminal( cls, diff --git a/backend/agent_kv/tests/test_adapter_scoping.py b/backend/agent_kv/tests/test_adapter_scoping.py index 7a060c74eb..b5a6f59c44 100644 --- a/backend/agent_kv/tests/test_adapter_scoping.py +++ b/backend/agent_kv/tests/test_adapter_scoping.py @@ -431,259 +431,3 @@ def test_a_stale_in_memory_row_cannot_override_the_database(self): job.dispatched_at = None assert _never_dispatched(job) is False - - -class TestACancelThatLostTheRaceKeepsItsResult(TestCase): - """A cancel landing after extraction finished must not destroy the answer. - - The window is real and the money is already gone when it opens: the engine - flushes OCR and LLM usage before the executor's final stage report, so by - the time a cancel can collide with that report the customer has been - billed. `FinalizeView` then wrote the result, `mark_terminal` correctly - declined to restatus a terminal row, and the file was DELETED -- so - `GET result` returned `{"success": false, "status": "cancelled"}` with no - sign that a paid-for result had existed. - - DB-backed because the whole mechanism is a guarded UPDATE racing another - guarded UPDATE; a mock would just agree with whichever one I wrote. - """ - - def setUp(self): - self.org = Organization.objects.create( - display_name="Acme", organization_id="org_acme", name="acme" - ) - UserContext.set_organization_identifier(None) - - def _job(self, **kwargs): - from agent_kv.models import AgentKVJob, JobExtractor - - # `input_ref` defaults to a live staged input: a cancelled job keeps - # its input until TTL, and a BLANK one is the purge marker the salvage - # must refuse (see `test_a_purged_job_does_not_get_its_result_back`). - kwargs.setdefault("input_ref", "org/o/agent_kv/j/input.pdf") - job = AgentKVJob( - organization_id=self.org.id, extractor=JobExtractor.TABLE, **kwargs - ) - job.save() - return job - - def test_the_result_is_attached_to_a_cancelled_job(self): - from agent_kv.models import AgentKVJob, JobStatus - - job = self._job(status=JobStatus.CANCELLED) - - attached = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/1.json", usage_summary={"pages": 3} - ) - job.refresh_from_db() - - assert attached is True - assert job.result_ref == "r/1.json" - assert job.usage_summary == {"pages": 3} - - def test_it_never_changes_status_or_completed_at(self): - """The cancel stays won, and stays visible. - - If this ever starts flipping `status`, a cancelled job would read as - completed and the guard `mark_terminal` exists to enforce would have - been bypassed by the back door. - """ - from agent_kv.models import AgentKVJob, JobStatus - from django.utils import timezone - - stamped = timezone.now() - job = self._job(status=JobStatus.CANCELLED, completed_at=stamped) - - AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/2.json" - ) - job.refresh_from_db() - - assert job.status == JobStatus.CANCELLED - assert job.completed_at == stamped - - def test_a_completed_or_failed_job_is_left_alone(self): - """Scoped to CANCELLED on purpose. - - A COMPLETED or FAILED row already has its own outcome; grafting a late - callback's result onto it would overwrite a real one (COMPLETED) or - invent one for a run that failed. - """ - from agent_kv.models import AgentKVJob, JobStatus - - for status in (JobStatus.COMPLETED, JobStatus.FAILED): - job = self._job(status=status) - attached = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/nope.json" - ) - job.refresh_from_db() - assert attached is False, status - assert job.result_ref == "", status - - def test_a_second_callback_does_not_overwrite_the_first(self): - """At-least-once delivery means this runs twice. - - The second call must report False so its caller deletes its own freshly - written file rather than orphaning it -- `result_ref=""` in the filter - is what makes that happen. - """ - from agent_kv.models import AgentKVJob, JobStatus - - job = self._job(status=JobStatus.CANCELLED) - - first = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/first.json" - ) - second = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/second.json" - ) - job.refresh_from_db() - - assert (first, second) == (True, False) - assert job.result_ref == "r/first.json" - - def test_a_blank_ref_is_refused_so_the_caller_keeps_its_delete(self): - from agent_kv.models import AgentKVJob, JobStatus - - job = self._job(status=JobStatus.CANCELLED) - - assert ( - AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref="") - is False - ) - - def test_another_org_cannot_attach_a_result(self): - from agent_kv.models import AgentKVJob, JobStatus - - other = Organization.objects.create( - display_name="Other", organization_id="org_other", name="other" - ) - job = self._job(status=JobStatus.CANCELLED) - - assert ( - AgentKVJob.attach_result_to_cancelled( - job.id, other.id, result_ref="r/x.json" - ) - is False - ) - - def test_the_result_payload_serves_it_while_still_reporting_cancelled(self): - """The read half. `status` must stay `cancelled` -- the cancel WAS - honoured and a client has to be able to see that -- while `success` is - True because there is a real, paid-for result to hand over. - """ - from agent_kv.execution_views_result import result_payload - from agent_kv.models import JobStatus - from unittest import mock - - job = self._job(status=JobStatus.CANCELLED, result_ref="r/3.json") - - with mock.patch( - "agent_kv.execution_views_result.read_result", - return_value={"tables": [], "row_count": 0}, - ): - payload = result_payload(job) - - assert payload["status"] == "cancelled" - assert payload["success"] is True - assert payload["extractors"]["table"] == {"tables": [], "row_count": 0} - - def test_an_ordinary_cancel_is_byte_identical_to_before(self): - """The common path -- cancelled mid-run, nothing computed -- must not - change shape just because the salvage path exists. - """ - from agent_kv.execution_views_result import result_payload - from agent_kv.models import JobStatus - - job = self._job(status=JobStatus.CANCELLED) - - assert result_payload(job) == {"success": False, "status": "cancelled"} - - -class TestThePurgeGuardOnTheSalvage(TestCase): - """A result the caller DELETEd must not come back. - - `result_ref=""` alone cannot distinguish "cancelled, nothing stored yet" - from "cancelled and explicitly purged": `JobDeleteView` deletes the files - and blanks BOTH refs, which is exactly the row the salvage filter matched. - A late finalize would then re-attach a newly written result to a job whose - data the caller asked to have destroyed. - - `input_ref` is the discriminator: a cancelled job keeps its staged input - (only a WINNING finalize drops it, and the salvage path is by definition a - losing one), while DELETE and TTL cleanup both blank it. - """ - - def setUp(self): - self.org = Organization.objects.create( - display_name="Acme", organization_id="org_acme", name="acme" - ) - UserContext.set_organization_identifier(None) - - def _cancelled(self, **kwargs): - from agent_kv.models import AgentKVJob, JobExtractor, JobStatus - - job = AgentKVJob( - organization_id=self.org.id, - extractor=JobExtractor.TABLE, - status=JobStatus.CANCELLED, - **kwargs, - ) - job.save() - return job - - def test_a_purged_job_does_not_get_its_result_back(self): - """The deletion violation. Both refs blank = the caller purged it.""" - from agent_kv.models import AgentKVJob - - job = self._cancelled(input_ref="", result_ref="") - - attached = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="org/o/agent_kv/j/result.json" - ) - job.refresh_from_db() - - assert attached is False, ( - "re-attached a result to a job the caller DELETEd; purged data " - "must not become readable again" - ) - assert job.result_ref == "" - - def test_a_live_cancelled_job_still_gets_its_result(self): - """The other half: the guard must not refuse the case it exists for.""" - from agent_kv.models import AgentKVJob - - job = self._cancelled(input_ref="org/o/agent_kv/j/input.pdf", result_ref="") - - attached = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="org/o/agent_kv/j/result.json" - ) - job.refresh_from_db() - - assert attached is True - assert job.result_ref == "org/o/agent_kv/j/result.json" - - def test_a_ttl_swept_job_does_not_get_a_result_grafted_on(self): - """TTL cleanup blanks both refs, same shape as DELETE. - - Re-attaching there would put a result on a job past its retention - window, which `JobResultView`'s expiry gate would then 404 anyway -- - but the file would exist, unreferenced-but-reachable, after the - retention window the customer was promised. - """ - from agent_kv.models import AgentKVJob - from django.utils import timezone - from datetime import timedelta - - job = self._cancelled( - input_ref="", - result_ref="", - expires_at=timezone.now() - timedelta(days=1), - ) - - assert ( - AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="org/o/agent_kv/j/late.json" - ) - is False - ) diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py index 42d74760e3..20cba4c677 100644 --- a/backend/agent_kv/tests/test_internal_views.py +++ b/backend/agent_kv/tests/test_internal_views.py @@ -412,21 +412,13 @@ def test_finalize_success_deletes_input_and_blanks_ref( # cancel raced in between the read and mark_terminal's guarded UPDATE) -- # the just-written result file has no ref anywhere pointing at it, so it's # cleaned up immediately rather than orphaned forever. -# `attach_result_to_cancelled` is patched to False here, which is what the real -# DB returns for these jobs: its filter is `status=CANCELLED, result_ref=""`, -# and the rows in these two cases are RUNNING and COMPLETED. Without the patch -# the mocked `AgentKVJob.objects` makes its `.update()` return a Mock, which is -# truthy, so the salvage would appear to win and the orphan delete these tests -# exist to assert would silently stop being called. The salvage path itself is -# covered DB-backed in `test_adapter_scoping.py`. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @mock.patch.object(iv, "delete_result_file") -@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) @mock.patch.object(iv, "write_result") @mock.patch.object(AgentKVJob, "objects") def test_finalize_success_guard_loss_deletes_orphaned_result( - m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release + m_jobs, m_write, m_mark_terminal, m_delete_result, m_release ): job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") m_jobs.filter.return_value.first.return_value = job @@ -446,52 +438,6 @@ def test_finalize_success_guard_loss_deletes_orphaned_result( m_delete_result.assert_called_once_with("org/o/agent_kv/j/result.json") -# (7c-ii) the case (7c) above must NOT catch: the guard was lost to a CANCEL, -# and the run had already finished and been billed. Deleting the result there -# charged the customer for an answer and then destroyed it, with `GET result` -# reporting only `{"success": false, "status": "cancelled"}`. The result is -# salvaged onto the cancelled row instead. -@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") -@mock.patch.object(iv, "delete_result_file") -@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=True) -@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) -@mock.patch.object(iv, "write_result") -@mock.patch.object(AgentKVJob, "objects") -def test_finalize_guard_lost_to_a_cancel_salvages_the_paid_for_result( - m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release -): - job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") - m_jobs.filter.return_value.first.return_value = job - m_write.return_value = "org/o/agent_kv/j/result.json" - - job_id = uuid.uuid4() - resp = iv.FinalizeView.as_view()( - _post( - "/x", - { - "org_id": "org1", - "success": True, - "result": {"tables": []}, - "usage_summary": {"pages": 3}, - }, - ), - job_id=job_id, - ) - - assert resp.status_code == 200 - # Still not "finalized": the cancel won the status, and this must not - # pretend otherwise. - assert resp.data["finalized"] is False - m_salvage.assert_called_once() - kwargs = m_salvage.call_args.kwargs - assert kwargs["result_ref"] == "org/o/agent_kv/j/result.json" - assert kwargs["usage_summary"] == {"pages": 3} - assert not m_delete_result.called, ( - "the result was deleted after the customer had already been billed " - "for it; a cancelled job keeps a result it paid for" - ) - - # (7d) success finalize that WINS the guard never deletes the result it just # wrote -- only a guard-LOSS orphan triggers cleanup. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @@ -526,14 +472,10 @@ def test_finalize_success_guard_win_does_not_delete_result( # result.json path let the loser delete the winner's live result). @mock.patch.object(storage, "FileSystem") @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") -# False for the same reason as the guard-loss case above: the row the loser -# finds is COMPLETED, not CANCELLED, so the real filter matches nothing and the -# loser must still delete the file it just wrote. -@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", side_effect=[True, False]) @mock.patch.object(AgentKVJob, "objects") def test_finalize_duplicate_success_race_loser_deletes_only_its_own_result( - m_jobs, m_mark, m_salvage, m_release, m_fs + m_jobs, m_mark, m_release, m_fs ): # Two separate reads that both observed the job as still RUNNING (the race # window before either guarded UPDATE ran). diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index e8aad5be3d..0c2f7e06c6 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -483,24 +483,7 @@ Re-readable until `expires_at` (D11), plus explicit `DELETE`. finalize call sent). - **`FAILED`**: `200` with `{"success": false, "status": "failed", "error": ""}`. -- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}` — the - ordinary case, where nothing ran or the run was stopped mid-flight. - - **Exception: a cancel that lost a race to a finished extraction keeps its - result.** The window is narrow but the money is already gone when it opens: - the engine flushes OCR and LLM usage *before* the executor's final stage - report, so a cancel colliding with that report arrives after the customer - has been billed. Such a job returns `200` with - `{"success": true, "status": "cancelled", "extractors": {...}, "usage_summary": {...}}` - — the full result, with `status` still `cancelled` because the cancel *was* - honoured. Branch on `success` for "is there data"; branch on `status` for - "was it cancelled". Previously the result was written and then deleted, so - the caller paid for an answer and received - `{"success": false, "status": "cancelled"}` with no indication it had ever - existed. The job is **not** reported as `completed` — the cancel still owns - the status — and the result rides the same `AGENT_KV_RESULT_TTL_DAYS` window - as any other, after which this degrades back to the ordinary shape. It is - also refused outright if you called `DELETE` on the job: the purge wins, and - a late result is not resurrected. +- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}`. **Calculation result shape** (when `calculations` is supplied and `AGENT_KV_CALCULATIONS_ENABLED=true`): a `COMPLETED` result adds these fields to the @@ -653,21 +636,24 @@ the engine's own progress points (rate-limited, so detection can lag by a few se and the job then terminates as `cancelled`. Work already done is still billed and metered; work not yet started is not. -**A job that finishes before the cancel is noticed KEEPS its result.** This used -to say the result was "discarded on arrival", which was accurate and was the -problem: the engine flushes OCR and LLM usage before the executor's final stage -report, so the customer had already been billed for an answer that was then -written and deleted. Such a job stays `cancelled` — the cancel is honoured and -visible — but `GET /agent-kv/{job_id}/result/` serves the result with -`success: true` (see [§5](#7-get-result)). Fetch it; you have paid for it. - -Two cases where it genuinely is gone, both deliberate: - -- You called `DELETE /agent-kv/{job_id}/` — the purge wins, and a late result is - refused rather than resurrected. -- The job passed `AGENT_KV_RESULT_TTL_DAYS` and TTL cleanup swept it, after - which the endpoint reports the ordinary - `{"success": false, "status": "cancelled"}`. +> **Known gap: a job that finishes before the cancel is noticed has its result +> discarded on arrival, and you are still billed for it.** The engine flushes OCR +> and LLM usage *before* the executor's final stage report, so a cancel colliding +> with that report lands after the work is paid for. `FinalizeView` then writes +> the result, its `mark_terminal` guard correctly declines to restatus a terminal +> row, and the file is deleted — `GET result` reports +> `{"success": false, "status": "cancelled"}` with no indication that a +> fully-computed answer existed. +> +> This is not an oversight, it is an unresolved trade. Keeping the result was +> tried and reverted: `DELETE /agent-kv/{job_id}/` relies on exactly that +> self-cleanup (see the comment at the top of `JobStatusView.delete`) to +> guarantee a purged job cannot be resurrected by a late finalize, so storing +> the result instead reopened both a purged-data-returns path and a storage +> orphan. Closing it properly needs an explicit "files purged" marker that +> `DELETE`, TTL cleanup and finalize all agree on, rather than inferring it from +> blank refs. Tracked separately; the window is narrow, and the caller did ask +> to cancel. - Won the race (job was non-terminal): `200` `{"status": "cancelled"}`. - Already terminal: `409` `{"status": ""}`, lowercased (e.g. From d747a32608d5962bad74933b2596c9c4cc2bab5b Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Mon, 12 Oct 2026 15:24:20 +0530 Subject: [PATCH 26/54] UN-4232 [FIX] Keep a cancelled job's paid-for result, and make DELETE own the purge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reinstates 785cee5d6 + c0726bae3 (backed out in ad218c8f0) with the root cause actually addressed this time. The defect: the engine flushes OCR and LLM usage BEFORE the executor's final stage report, so a cancel colliding with that report lands after the customer has been billed. `FinalizeView` wrote the result, `mark_terminal` correctly declined to restatus a terminal row, and the file was deleted -- money taken, answer destroyed, `GET result` reporting only `{"success": false, "status": "cancelled"}`. Why the first two attempts were wrong, since that is the useful part: `JobStatusView.delete` guaranteed a purged job could not be resurrected BY RELYING on a late finalize deleting its own orphaned write -- stated outright in the comment at the top of that method. Keeping the result removed exactly that cleanup, so a job the caller DELETEd could end up serving one. The `input_ref__gt=""` guard narrowed it but could not close it: at the moment the late finalize attaches, DELETE has not yet blanked `input_ref`. So DELETE now owns the cleanup instead of delegating it. It re-reads the row immediately before `delete_job_files`, so a result attached mid-flight is deleted with everything else. Three guards, each covering a different window: 1. DELETE re-reads before deleting -> a result that already landed is purged. 2. `attach_result_to_cancelled`'s `input_ref__gt=""` -> once DELETE (or TTL cleanup) has blanked the input, no later callback can attach at all. 3. `result_ref=""` -> at-least-once idempotency; a second callback deletes its own write rather than orphaning it. Residual is a storage orphan only, never a disclosure: a finalize landing between DELETE's re-read and its blanking save sets a ref DELETE then clears without deleting the file. Documented at both sites. The re-read is scoped to `won or job.status == CANCELLED`, which is the only state a salvage can attach to -- a COMPLETED/FAILED row owns its outcome and nothing can write a result to it, so the common path keeps its original single-query behaviour. **`won` is in that condition for a reason worth recording.** My first cut tested only `job.status == CANCELLED`. `mark_terminal` is a queryset UPDATE and never touches the in-memory row, so after this request wins the cancel `job.status` is still RUNNING -- the re-read was skipped on precisely the path that needs it. All 34 existing tests passed, because their mocked `mark_terminal` leaves the row unmutated in exactly the same way. `test_delete_DOES_refresh_when_it_wins_ the_cancel` now pins it and fails against that mistake; it replaces `test_delete_does_not_refresh_when_it_wins_the_terminal_race`, whose stated premise ("winning means nothing else wrote to the row") the salvage invalidates. `test_delete_on_a_completed_job_skips_the_extra_query` holds the other side so the re-read cannot quietly become unconditional. Tests: 11 DB-backed cases on the salvage and its purge guard, one driving `FinalizeView`, two on the DELETE re-read. Four existing DB-free tests gained a `refresh_from_db` patch with a comment saying why. Mutation-checked three ways: removing the salvage, removing the purge guard, and dropping `won` from the re-read condition each fail a test that names the consequence. Docs §8 and §7.3 state the behaviour and both exceptions (DELETE, TTL). Verification: agent_kv 317 passed; `makemigrations --check` clean; ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 55 +++- backend/agent_kv/execution_views_result.py | 29 +- backend/agent_kv/internal_views.py | 23 +- backend/agent_kv/models.py | 77 ++++++ .../agent_kv/tests/test_adapter_scoping.py | 256 ++++++++++++++++++ backend/agent_kv/tests/test_internal_views.py | 62 ++++- backend/agent_kv/tests/test_job_views.py | 88 +++++- docs/agent-kv-api.md | 55 ++-- 8 files changed, 604 insertions(+), 41 deletions(-) diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index 28b021a84d..b2731e3e8c 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -659,15 +659,24 @@ def get(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): @AgentKVKeyValidator.validate_api_key def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): job = _get_job(agent_kv_key, job_id) + won = False + reread = False if job.status not in AgentKVJob.TERMINAL: # Cancel BEFORE deleting files: a still-running job would # otherwise keep running after its files are gone, and its # eventual finalize call would write a fresh result_ref onto a # job the caller already asked to delete -- resurrecting a # result they explicitly discarded. Terminalizing first closes - # that window; a finalize call that still lands late loses the - # terminal-state guard and, on the success path, cleans up its - # own now-orphaned write (FinalizeView, storage.delete_result_file). + # that window. + # + # This used to add "and a finalize call that still lands late + # cleans up its own now-orphaned write". That is no longer the + # mechanism: a losing finalize on a CANCELLED row now ATTACHES its + # result instead of deleting it, so the purge is enforced by the + # unconditional `refresh_from_db()` before `delete_job_files` + # below, plus the `input_ref__gt=""` guard in + # `AgentKVJob.attach_result_to_cancelled`. Read both before + # changing either. won = AgentKVJob.mark_terminal( job.id, job.organization_id, JobStatus.CANCELLED ) @@ -725,6 +734,46 @@ def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): # back. Deleting is still the caller's intent; it just has to # act on the refs that actually exist now. job.refresh_from_db() + reread = True + # Re-read before deleting files, not just on the lost-race branch above. + # + # This is what lets a cancelled job keep a result it paid for + # (`AgentKVJob.attach_result_to_cancelled`) without that result + # outliving a DELETE. The comment at the top of this method used to be + # able to say "a late finalize cleans up its own orphaned write" -- + # that stopped being true when a losing finalize started ATTACHING its + # result to a cancelled row instead of deleting it. So the cleanup has + # to happen here, from the row as it actually is now. + # + # Without this, the interleaving is: + # DELETE reads the row -> result_ref "" + # cancel wins, job CANCELLED + # late finalize attaches -> result_ref set, file written + # DELETE deletes files from its STALE copy -> input only + # DELETE blanks input_ref + # leaving a job the caller purged still serving a result. The re-read + # closes that: DELETE sees the attached ref and deletes that file too. + # + # Residual is a storage orphan only, never a disclosure: a finalize + # landing between this re-read and the blanking save sets a ref DELETE + # then clears without deleting its file. `attach_result_to_cancelled`'s + # `input_ref__gt=""` guard closes the other side -- once DELETE has + # blanked the input, no later finalize can attach at all. + # + # Scoped to the CANCELLED case, which is the only status a salvage can + # attach to (see that method's filter). A COMPLETED or FAILED row owns + # its outcome and nothing can write a result to it, so the extra query + # would be waste on the common path -- which is what the + # lost-race-only refresh above was originally reasoning about. + # + # `won` is in the condition and NOT just `job.status`: `mark_terminal` + # is a queryset UPDATE and does not touch the in-memory row, so after + # this request wins the cancel `job.status` is still RUNNING/PENDING. + # Testing only the attribute skipped the re-read on exactly the path + # that needs it -- and the DB-free unit tests agreed, because their + # mocked `mark_terminal` leaves the row unmutated in the same way. + if not reread and (won or job.status == JobStatus.CANCELLED): + job.refresh_from_db() # Blank only the refs whose files are confirmed gone, so a ref whose # delete failed survives as the handle TTL cleanup retries from. 204 # either way: the job IS terminal and the caller's intent is recorded, diff --git a/backend/agent_kv/execution_views_result.py b/backend/agent_kv/execution_views_result.py index 5ec465a604..2233dfb720 100644 --- a/backend/agent_kv/execution_views_result.py +++ b/backend/agent_kv/execution_views_result.py @@ -25,7 +25,10 @@ def result_payload(job) -> dict: - COMPLETED: ``{success, status, extractors, usage_summary}`` -- the stored engine result namespaced per extractor (spec §7.3), not the bare blob. - FAILED: ``{"success": False, "status": "failed", "error": }``. - - CANCELLED: ``{"success": False, "status": "cancelled"}``. + - CANCELLED: ``{"success": False, "status": "cancelled"}`` -- unless the + cancel lost a race to a completed extraction, in which case the paid-for + result is served with ``success: True`` and ``status: "cancelled"`` (see + ``AgentKVJob.attach_result_to_cancelled``). Callers are responsible for confirming ``job.status`` is terminal (and, for COMPLETED, that ``result_ref`` is non-blank and unexpired) before @@ -60,4 +63,28 @@ def result_payload(job) -> dict: "status": "failed", "error": job.error or "Job failed", } + # A cancel that lost the race to a completed extraction keeps its result + # (`AgentKVJob.attach_result_to_cancelled`): the engine had already spent + # and flushed the OCR/LLM usage, so the customer is billed either way, and + # discarding the answer was the strictly worse half of that trade. + # + # `status` stays `cancelled` -- the cancel WAS honoured and a client must + # still be able to see that -- but `success` is True and the result is + # served, because there is a real result and it was paid for. A client that + # branches only on `success` gets the data; one that branches on `status` + # sees the cancellation. Neither is misled. + # + # `result_ref` is blank for the ordinary cancel (nothing ever ran, or the + # run was stopped mid-flight), which keeps that path byte-identical. + if job.result_ref: + usage = job.usage_summary or {} + return { + "success": True, + "status": "cancelled", + "extractors": {job.extractor: read_result(job.result_ref)}, + "usage_summary": { + "total": usage, + "by_extractor": {job.extractor: usage}, + }, + } return {"success": False, "status": "cancelled"} diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index de4583f5ff..29b1681591 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -198,6 +198,25 @@ class FinalizeView(APIView): def _complete(job, job_id, org_id: str, body) -> bool: """Persist the result and terminalize as COMPLETED. True if the guard won. + **A cancel that lost the race does not forfeit the result.** The window + is real: the executor can finish extraction -- spending OCR and LLM -- + and only then try its final stage report, which gets a ``noop`` because + a cancel terminalized the row in the meantime. The executor returns + success with the full output, this method writes it, and the guard below + correctly declines to change a terminal row's status. Deleting the file + at that point billed the customer for an answer and then destroyed it: + usage is flushed by the engine before any of this runs, so the money is + already spent either way, and `GET result` reported + ``{"success": false, "status": "cancelled"}`` with no indication that a + paid-for result had existed and been discarded. + + So a CANCELLED row keeps the result instead: ``result_ref`` and + ``usage_summary`` are attached WITHOUT touching ``status`` or + ``completed_at``, the cancel stays honoured, and ``result_payload`` + serves the data alongside ``status: "cancelled"``. The terminal guard + still owns the status transition -- this only adds a pointer to work + that was already paid for. + ``write_result`` unavoidably runs *before* the ``mark_terminal`` guard (the result has to exist to get a ``result_ref`` to pass in), so a guard-loss right after the write -- a concurrent cancel or duplicate @@ -216,7 +235,9 @@ def _complete(job, job_id, org_id: str, body) -> bool: ) if finalized: job.status = JobStatus.COMPLETED - else: + elif not AgentKVJob.attach_result_to_cancelled( + job_id, org_id, result_ref=result_ref, usage_summary=body.get("usage_summary") + ): delete_result_file(result_ref) return finalized diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index 45df84d8dd..eaabd767f6 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -163,6 +163,83 @@ class Meta: models.Index(fields=["cleanup_failed_at", "expires_at"]), ] + @classmethod + def attach_result_to_cancelled( + cls, job_id, organization_id, *, result_ref, usage_summary=None + ) -> bool: + """Keep a paid-for result on a job that was CANCELLED mid-flight. + + The race this exists for: extraction completes -- OCR and LLM already + spent and flushed by the engine -- and only then does the executor's + final stage report land, by which time a cancel has terminalized the + row. The executor returns success with the full output, `FinalizeView` + writes it, and `mark_terminal` correctly declines to restatus a + terminal row. Before this, the written result was then DELETED: the + customer was billed for an answer and the answer was destroyed, with + `GET result` reporting only `{"success": false, "status": "cancelled"}`. + + Deliberately NOT part of `mark_terminal`, and deliberately unable to + change `status` or `completed_at`: the cancel stays won and stays + visible. This adds a pointer to work that was already paid for, nothing + more. `result_payload` then serves the data alongside + `status: "cancelled"`. + + Scoped to CANCELLED only. A COMPLETED or FAILED row already has its own + outcome and must not have a late callback's result grafted onto it -- + which is why this is not simply "attach to any terminal row". + + `result_ref=""` is the idempotency guard: a second at-least-once + callback finds the ref already set, matches zero rows, and its own + freshly written file is deleted by the caller rather than orphaned. + + **`input_ref__gt=""` is the purge guard, and it is the important one.** + `result_ref=""` alone cannot tell "cancelled, nothing stored yet" from + "cancelled and the caller explicitly DELETEd it" -- `JobDeleteView` + purges the files and blanks both refs, leaving exactly the row this + filter was matching. A late finalize would then have attached a newly + written result to a job whose data the caller had asked to be destroyed, + making it readable again. That is a deletion violation, not a + correctness nit, and it is strictly worse than the billing problem the + salvage exists to fix. + + `input_ref` discriminates them because a cancelled job KEEPS its staged + input (it rides `AGENT_KV_RESULT_TTL_DAYS`; only a winning finalize + drops it, via `_drop_staged_input`, which runs only when `finalized` is + True). So: + + * cancelled mid-flight, not deleted -> `input_ref` set -> salvage + * DELETEd by the caller -> `input_ref` blank -> refused + * swept by TTL cleanup -> `input_ref` blank -> refused + + Residual, stated rather than papered over: TTL cleanup can select this + row (its filter is `Q(input_ref__gt="") | Q(result_ref__gt="")`), read + an empty `result_ref`, and blank the refs after this update has set one + -- leaving the new result file orphaned in the bucket. A storage leak, + the same class the `JobDeleteView` refresh already documents, bounded by + the width of that one sweep, and strictly better than the alternative it + replaces, which was destroying a result the customer had paid for. + + Returns True when this call attached the result (and the caller must + therefore keep the file), False when it did not (caller deletes it). + """ + if not result_ref: + return False + fields = {"result_ref": result_ref} + if usage_summary is not None: + fields["usage_summary"] = usage_summary + return bool( + cls.objects.filter( + id=job_id, + organization_id=organization_id, + status=JobStatus.CANCELLED, + result_ref="", + # See the purge guard in the docstring: a blank `input_ref` + # means the caller DELETEd this job or TTL cleanup swept it. + # Re-attaching a result there resurrects purged data. + input_ref__gt="", + ).update(**fields) + ) + @classmethod def mark_terminal( cls, diff --git a/backend/agent_kv/tests/test_adapter_scoping.py b/backend/agent_kv/tests/test_adapter_scoping.py index b5a6f59c44..7a060c74eb 100644 --- a/backend/agent_kv/tests/test_adapter_scoping.py +++ b/backend/agent_kv/tests/test_adapter_scoping.py @@ -431,3 +431,259 @@ def test_a_stale_in_memory_row_cannot_override_the_database(self): job.dispatched_at = None assert _never_dispatched(job) is False + + +class TestACancelThatLostTheRaceKeepsItsResult(TestCase): + """A cancel landing after extraction finished must not destroy the answer. + + The window is real and the money is already gone when it opens: the engine + flushes OCR and LLM usage before the executor's final stage report, so by + the time a cancel can collide with that report the customer has been + billed. `FinalizeView` then wrote the result, `mark_terminal` correctly + declined to restatus a terminal row, and the file was DELETED -- so + `GET result` returned `{"success": false, "status": "cancelled"}` with no + sign that a paid-for result had existed. + + DB-backed because the whole mechanism is a guarded UPDATE racing another + guarded UPDATE; a mock would just agree with whichever one I wrote. + """ + + def setUp(self): + self.org = Organization.objects.create( + display_name="Acme", organization_id="org_acme", name="acme" + ) + UserContext.set_organization_identifier(None) + + def _job(self, **kwargs): + from agent_kv.models import AgentKVJob, JobExtractor + + # `input_ref` defaults to a live staged input: a cancelled job keeps + # its input until TTL, and a BLANK one is the purge marker the salvage + # must refuse (see `test_a_purged_job_does_not_get_its_result_back`). + kwargs.setdefault("input_ref", "org/o/agent_kv/j/input.pdf") + job = AgentKVJob( + organization_id=self.org.id, extractor=JobExtractor.TABLE, **kwargs + ) + job.save() + return job + + def test_the_result_is_attached_to_a_cancelled_job(self): + from agent_kv.models import AgentKVJob, JobStatus + + job = self._job(status=JobStatus.CANCELLED) + + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/1.json", usage_summary={"pages": 3} + ) + job.refresh_from_db() + + assert attached is True + assert job.result_ref == "r/1.json" + assert job.usage_summary == {"pages": 3} + + def test_it_never_changes_status_or_completed_at(self): + """The cancel stays won, and stays visible. + + If this ever starts flipping `status`, a cancelled job would read as + completed and the guard `mark_terminal` exists to enforce would have + been bypassed by the back door. + """ + from agent_kv.models import AgentKVJob, JobStatus + from django.utils import timezone + + stamped = timezone.now() + job = self._job(status=JobStatus.CANCELLED, completed_at=stamped) + + AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/2.json" + ) + job.refresh_from_db() + + assert job.status == JobStatus.CANCELLED + assert job.completed_at == stamped + + def test_a_completed_or_failed_job_is_left_alone(self): + """Scoped to CANCELLED on purpose. + + A COMPLETED or FAILED row already has its own outcome; grafting a late + callback's result onto it would overwrite a real one (COMPLETED) or + invent one for a run that failed. + """ + from agent_kv.models import AgentKVJob, JobStatus + + for status in (JobStatus.COMPLETED, JobStatus.FAILED): + job = self._job(status=status) + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/nope.json" + ) + job.refresh_from_db() + assert attached is False, status + assert job.result_ref == "", status + + def test_a_second_callback_does_not_overwrite_the_first(self): + """At-least-once delivery means this runs twice. + + The second call must report False so its caller deletes its own freshly + written file rather than orphaning it -- `result_ref=""` in the filter + is what makes that happen. + """ + from agent_kv.models import AgentKVJob, JobStatus + + job = self._job(status=JobStatus.CANCELLED) + + first = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/first.json" + ) + second = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="r/second.json" + ) + job.refresh_from_db() + + assert (first, second) == (True, False) + assert job.result_ref == "r/first.json" + + def test_a_blank_ref_is_refused_so_the_caller_keeps_its_delete(self): + from agent_kv.models import AgentKVJob, JobStatus + + job = self._job(status=JobStatus.CANCELLED) + + assert ( + AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref="") + is False + ) + + def test_another_org_cannot_attach_a_result(self): + from agent_kv.models import AgentKVJob, JobStatus + + other = Organization.objects.create( + display_name="Other", organization_id="org_other", name="other" + ) + job = self._job(status=JobStatus.CANCELLED) + + assert ( + AgentKVJob.attach_result_to_cancelled( + job.id, other.id, result_ref="r/x.json" + ) + is False + ) + + def test_the_result_payload_serves_it_while_still_reporting_cancelled(self): + """The read half. `status` must stay `cancelled` -- the cancel WAS + honoured and a client has to be able to see that -- while `success` is + True because there is a real, paid-for result to hand over. + """ + from agent_kv.execution_views_result import result_payload + from agent_kv.models import JobStatus + from unittest import mock + + job = self._job(status=JobStatus.CANCELLED, result_ref="r/3.json") + + with mock.patch( + "agent_kv.execution_views_result.read_result", + return_value={"tables": [], "row_count": 0}, + ): + payload = result_payload(job) + + assert payload["status"] == "cancelled" + assert payload["success"] is True + assert payload["extractors"]["table"] == {"tables": [], "row_count": 0} + + def test_an_ordinary_cancel_is_byte_identical_to_before(self): + """The common path -- cancelled mid-run, nothing computed -- must not + change shape just because the salvage path exists. + """ + from agent_kv.execution_views_result import result_payload + from agent_kv.models import JobStatus + + job = self._job(status=JobStatus.CANCELLED) + + assert result_payload(job) == {"success": False, "status": "cancelled"} + + +class TestThePurgeGuardOnTheSalvage(TestCase): + """A result the caller DELETEd must not come back. + + `result_ref=""` alone cannot distinguish "cancelled, nothing stored yet" + from "cancelled and explicitly purged": `JobDeleteView` deletes the files + and blanks BOTH refs, which is exactly the row the salvage filter matched. + A late finalize would then re-attach a newly written result to a job whose + data the caller asked to have destroyed. + + `input_ref` is the discriminator: a cancelled job keeps its staged input + (only a WINNING finalize drops it, and the salvage path is by definition a + losing one), while DELETE and TTL cleanup both blank it. + """ + + def setUp(self): + self.org = Organization.objects.create( + display_name="Acme", organization_id="org_acme", name="acme" + ) + UserContext.set_organization_identifier(None) + + def _cancelled(self, **kwargs): + from agent_kv.models import AgentKVJob, JobExtractor, JobStatus + + job = AgentKVJob( + organization_id=self.org.id, + extractor=JobExtractor.TABLE, + status=JobStatus.CANCELLED, + **kwargs, + ) + job.save() + return job + + def test_a_purged_job_does_not_get_its_result_back(self): + """The deletion violation. Both refs blank = the caller purged it.""" + from agent_kv.models import AgentKVJob + + job = self._cancelled(input_ref="", result_ref="") + + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="org/o/agent_kv/j/result.json" + ) + job.refresh_from_db() + + assert attached is False, ( + "re-attached a result to a job the caller DELETEd; purged data " + "must not become readable again" + ) + assert job.result_ref == "" + + def test_a_live_cancelled_job_still_gets_its_result(self): + """The other half: the guard must not refuse the case it exists for.""" + from agent_kv.models import AgentKVJob + + job = self._cancelled(input_ref="org/o/agent_kv/j/input.pdf", result_ref="") + + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="org/o/agent_kv/j/result.json" + ) + job.refresh_from_db() + + assert attached is True + assert job.result_ref == "org/o/agent_kv/j/result.json" + + def test_a_ttl_swept_job_does_not_get_a_result_grafted_on(self): + """TTL cleanup blanks both refs, same shape as DELETE. + + Re-attaching there would put a result on a job past its retention + window, which `JobResultView`'s expiry gate would then 404 anyway -- + but the file would exist, unreferenced-but-reachable, after the + retention window the customer was promised. + """ + from agent_kv.models import AgentKVJob + from django.utils import timezone + from datetime import timedelta + + job = self._cancelled( + input_ref="", + result_ref="", + expires_at=timezone.now() - timedelta(days=1), + ) + + assert ( + AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="org/o/agent_kv/j/late.json" + ) + is False + ) diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py index 20cba4c677..42d74760e3 100644 --- a/backend/agent_kv/tests/test_internal_views.py +++ b/backend/agent_kv/tests/test_internal_views.py @@ -412,13 +412,21 @@ def test_finalize_success_deletes_input_and_blanks_ref( # cancel raced in between the read and mark_terminal's guarded UPDATE) -- # the just-written result file has no ref anywhere pointing at it, so it's # cleaned up immediately rather than orphaned forever. +# `attach_result_to_cancelled` is patched to False here, which is what the real +# DB returns for these jobs: its filter is `status=CANCELLED, result_ref=""`, +# and the rows in these two cases are RUNNING and COMPLETED. Without the patch +# the mocked `AgentKVJob.objects` makes its `.update()` return a Mock, which is +# truthy, so the salvage would appear to win and the orphan delete these tests +# exist to assert would silently stop being called. The salvage path itself is +# covered DB-backed in `test_adapter_scoping.py`. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) @mock.patch.object(iv, "write_result") @mock.patch.object(AgentKVJob, "objects") def test_finalize_success_guard_loss_deletes_orphaned_result( - m_jobs, m_write, m_mark_terminal, m_delete_result, m_release + m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release ): job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") m_jobs.filter.return_value.first.return_value = job @@ -438,6 +446,52 @@ def test_finalize_success_guard_loss_deletes_orphaned_result( m_delete_result.assert_called_once_with("org/o/agent_kv/j/result.json") +# (7c-ii) the case (7c) above must NOT catch: the guard was lost to a CANCEL, +# and the run had already finished and been billed. Deleting the result there +# charged the customer for an answer and then destroyed it, with `GET result` +# reporting only `{"success": false, "status": "cancelled"}`. The result is +# salvaged onto the cancelled row instead. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=True) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_guard_lost_to_a_cancel_salvages_the_paid_for_result( + m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release +): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") + m_jobs.filter.return_value.first.return_value = job + m_write.return_value = "org/o/agent_kv/j/result.json" + + job_id = uuid.uuid4() + resp = iv.FinalizeView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "success": True, + "result": {"tables": []}, + "usage_summary": {"pages": 3}, + }, + ), + job_id=job_id, + ) + + assert resp.status_code == 200 + # Still not "finalized": the cancel won the status, and this must not + # pretend otherwise. + assert resp.data["finalized"] is False + m_salvage.assert_called_once() + kwargs = m_salvage.call_args.kwargs + assert kwargs["result_ref"] == "org/o/agent_kv/j/result.json" + assert kwargs["usage_summary"] == {"pages": 3} + assert not m_delete_result.called, ( + "the result was deleted after the customer had already been billed " + "for it; a cancelled job keeps a result it paid for" + ) + + # (7d) success finalize that WINS the guard never deletes the result it just # wrote -- only a guard-LOSS orphan triggers cleanup. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @@ -472,10 +526,14 @@ def test_finalize_success_guard_win_does_not_delete_result( # result.json path let the loser delete the winner's live result). @mock.patch.object(storage, "FileSystem") @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +# False for the same reason as the guard-loss case above: the row the loser +# finds is COMPLETED, not CANCELLED, so the real filter matches nothing and the +# loser must still delete the file it just wrote. +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", side_effect=[True, False]) @mock.patch.object(AgentKVJob, "objects") def test_finalize_duplicate_success_race_loser_deletes_only_its_own_result( - m_jobs, m_mark, m_release, m_fs + m_jobs, m_mark, m_salvage, m_release, m_fs ): # Two separate reads that both observed the job as still RUNNING (the race # window before either guarded UPDATE ran). diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index 5d61eb6356..ae338eb5e6 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -412,14 +412,19 @@ def test_delete_calls_delete_job_files_and_blanks_refs( # files are deleted -- a still-running job that finalizes late would # otherwise write a fresh result onto a job the caller just deleted. # --------------------------------------------------------------------------- +# `refresh_from_db` is patched because DELETE now re-reads the row before +# deleting files whenever it has just cancelled the job -- a late finalize can +# attach a result to a CANCELLED row, and deleting from the pre-cancel snapshot +# would leave that result on a purged job. This suite is DB-free, so the real +# query raises; the behaviour itself is asserted in +# `test_delete_DOES_refresh_when_it_wins_the_cancel`. +@mock.patch.object(AgentKVJob, "refresh_from_db") @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files") @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_on_running_job_cancels_before_deleting_files( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save -): +def test_delete_on_running_job_cancels_before_deleting_files(m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_refresh): m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.RUNNING) job.organization_id = "org1" @@ -447,15 +452,20 @@ def test_delete_on_running_job_cancels_before_deleting_files( # leaked a slot until the 6h TTL, and enough of them exhaust the org's # allowance and start rejecting new submits. # --------------------------------------------------------------------------- +# `refresh_from_db` is patched because DELETE now re-reads the row before +# deleting files whenever it has just cancelled the job -- a late finalize can +# attach a result to a CANCELLED row, and deleting from the pre-cancel snapshot +# would leave that result on a purged job. This suite is DB-free, so the real +# query raises; the behaviour itself is asserted in +# `test_delete_DOES_refresh_when_it_wins_the_cancel`. +@mock.patch.object(AgentKVJob, "refresh_from_db") @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_on_a_running_job_does_NOT_release_the_slot( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release -): +def test_delete_on_a_running_job_does_NOT_release_the_slot(m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_refresh): """Inverted: this used to assert the release, which was the bug. Nothing revokes a running executor -- `job.task_id` is written at dispatch @@ -483,6 +493,13 @@ def test_delete_on_a_running_job_does_NOT_release_the_slot( ) +# `refresh_from_db` is patched because DELETE now re-reads the row before +# deleting files whenever it has just cancelled the job -- a late finalize can +# attach a result to a CANCELLED row, and deleting from the pre-cancel snapshot +# would leave that result on a purged job. This suite is DB-free, so the real +# query raises; the behaviour itself is asserted in +# `test_delete_DOES_refresh_when_it_wins_the_cancel`. +@mock.patch.object(AgentKVJob, "refresh_from_db") @mock.patch.object(ev, "dispatch_cancelled_webhook") @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @@ -490,9 +507,7 @@ def test_delete_on_a_running_job_does_NOT_release_the_slot( @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_on_a_never_dispatched_job_DOES_release_the_slot( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook -): +def test_delete_on_a_never_dispatched_job_DOES_release_the_slot(m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook, m_refresh): """The case the release exists for, and the only one. A job cancelled before dispatch gets no finalize callback, and the sweep's @@ -689,17 +704,55 @@ def _record(j): @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_does_not_refresh_when_it_wins_the_terminal_race( +def test_delete_DOES_refresh_when_it_wins_the_cancel( m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release ): - """Winning means nothing else wrote to the row, so the in-memory copy is - current and the extra query would be waste on the common path. + """Winning the cancel is exactly when the re-read is needed. + + This test used to assert the opposite, on the reasoning that "winning means + nothing else wrote to the row, so the in-memory copy is current". That + stopped being true when a losing finalize started ATTACHING its result to a + CANCELLED row instead of deleting it: after this request wins the cancel, a + late finalize can still write a `result_ref`, and deleting files from the + stale copy would leave that result on a job the caller purged. + + Note `won` is what drives the re-read, not `job.status`. `mark_terminal` is + a queryset UPDATE and never touches the in-memory row, so `job.status` is + still RUNNING here -- a condition testing only the attribute would skip the + re-read on precisely this path, and this mock reproduces that faithfully + (which is how the first cut of the fix passed while being wrong). """ m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.RUNNING) job.organization_id = "org1" m_jobs.get.return_value = job + with mock.patch.object(AgentKVJob, "refresh_from_db") as m_refresh: + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + + assert resp.status_code == 204 + assert m_refresh.called, ( + "DELETE deleted files from a pre-cancel snapshot; a result attached by " + "a late finalize would survive the purge" + ) + + +@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "save") +@mock.patch.object(ev, "delete_job_files", return_value=["input_ref"]) +@mock.patch.object(AgentKVJob, "objects") +@mock.patch.object(AgentKVKey, "objects") +def test_delete_on_a_completed_job_skips_the_extra_query( + m_keys, m_jobs, m_delete_files, m_save, m_release +): + """The other side: nothing can attach a result to a COMPLETED or FAILED + row, so the re-read would be waste on the common path. + """ + m_keys.get.return_value = kv_key() + job = AgentKVJob(status=JobStatus.COMPLETED) + job.organization_id = "org1" + m_jobs.get.return_value = job + with mock.patch.object(AgentKVJob, "refresh_from_db") as m_refresh: resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) @@ -763,6 +816,13 @@ def test_cancel_that_loses_does_not_queue_a_webhook( assert not m_hook.called +# `refresh_from_db` is patched because DELETE now re-reads the row before +# deleting files whenever it has just cancelled the job -- a late finalize can +# attach a result to a CANCELLED row, and deleting from the pre-cancel snapshot +# would leave that result on a purged job. This suite is DB-free, so the real +# query raises; the behaviour itself is asserted in +# `test_delete_DOES_refresh_when_it_wins_the_cancel`. +@mock.patch.object(AgentKVJob, "refresh_from_db") @mock.patch.object(ev, "dispatch_cancelled_webhook") @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @@ -770,9 +830,7 @@ def test_cancel_that_loses_does_not_queue_a_webhook( @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_that_cancels_a_running_job_also_queues_the_webhook( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook -): +def test_delete_that_cancels_a_running_job_also_queues_the_webhook(m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook, m_refresh): """DELETE on a non-terminal job cancels it first; that cancellation is just as terminal, and just as invisible to the caller without this. """ diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 0c2f7e06c6..c38d4f5a3b 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -483,7 +483,24 @@ Re-readable until `expires_at` (D11), plus explicit `DELETE`. finalize call sent). - **`FAILED`**: `200` with `{"success": false, "status": "failed", "error": ""}`. -- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}`. +- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}` — the + ordinary case, where nothing ran or the run was stopped mid-flight. + - **Exception: a cancel that lost a race to a finished extraction keeps its + result.** The window is narrow but the money is already gone when it opens: + the engine flushes OCR and LLM usage *before* the executor's final stage + report, so a cancel colliding with that report arrives after the customer + has been billed. Such a job returns `200` with + `{"success": true, "status": "cancelled", "extractors": {...}, "usage_summary": {...}}` + — the full result, with `status` still `cancelled` because the cancel *was* + honoured. Branch on `success` for "is there data"; branch on `status` for + "was it cancelled". Previously the result was written and then deleted, so + the caller paid for an answer and received + `{"success": false, "status": "cancelled"}` with no indication it had ever + existed. The job is **not** reported as `completed` — the cancel still owns + the status — and the result rides the same `AGENT_KV_RESULT_TTL_DAYS` window + as any other, after which this degrades back to the ordinary shape. It is + also refused outright if you called `DELETE` on the job: the purge wins, and + a late result is not resurrected. **Calculation result shape** (when `calculations` is supplied and `AGENT_KV_CALCULATIONS_ENABLED=true`): a `COMPLETED` result adds these fields to the @@ -636,24 +653,24 @@ the engine's own progress points (rate-limited, so detection can lag by a few se and the job then terminates as `cancelled`. Work already done is still billed and metered; work not yet started is not. -> **Known gap: a job that finishes before the cancel is noticed has its result -> discarded on arrival, and you are still billed for it.** The engine flushes OCR -> and LLM usage *before* the executor's final stage report, so a cancel colliding -> with that report lands after the work is paid for. `FinalizeView` then writes -> the result, its `mark_terminal` guard correctly declines to restatus a terminal -> row, and the file is deleted — `GET result` reports -> `{"success": false, "status": "cancelled"}` with no indication that a -> fully-computed answer existed. -> -> This is not an oversight, it is an unresolved trade. Keeping the result was -> tried and reverted: `DELETE /agent-kv/{job_id}/` relies on exactly that -> self-cleanup (see the comment at the top of `JobStatusView.delete`) to -> guarantee a purged job cannot be resurrected by a late finalize, so storing -> the result instead reopened both a purged-data-returns path and a storage -> orphan. Closing it properly needs an explicit "files purged" marker that -> `DELETE`, TTL cleanup and finalize all agree on, rather than inferring it from -> blank refs. Tracked separately; the window is narrow, and the caller did ask -> to cancel. +**A job that finishes before the cancel is noticed KEEPS its result.** This used +to say the result was "discarded on arrival", which was accurate and was the +problem: the engine flushes OCR and LLM usage before the executor's final stage +report, so the customer had already been billed for an answer that was then +written and deleted. Such a job stays `cancelled` — the cancel is honoured and +visible — but `GET /agent-kv/{job_id}/result/` serves the result with +`success: true` (see [§5](#7-get-result)). Fetch it; you have paid for it. + +Two cases where it genuinely is gone, both deliberate: + +- You called `DELETE /agent-kv/{job_id}/` — the purge wins, by two independent + mechanisms: `DELETE` re-reads the row immediately before deleting files, so a + result that landed mid-flight is deleted along with everything else; and once + it has blanked the input ref, no later callback can attach one at all. A + result is never resurrected on a job you purged. +- The job passed `AGENT_KV_RESULT_TTL_DAYS` and TTL cleanup swept it, after + which the endpoint reports the ordinary + `{"success": false, "status": "cancelled"}`. - Won the race (job was non-terminal): `200` `{"status": "cancelled"}`. - Already terminal: `409` `{"status": ""}`, lowercased (e.g. From c01059d0fed7f92125866aeedd362a5c25aa9750 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Mon, 12 Oct 2026 15:36:59 +0530 Subject: [PATCH 27/54] UN-4232 [REVERT] Back out the cancelled-result salvage for good; document why Reverts d747a3260. Third attempt at this change, third set of real holes found in review -- two of them labelled security. Stopping, and recording the reason so the next attempt starts from the actual constraint rather than rediscovering it. The defect being chased is real: usage is flushed before the executor's final stage report, so a cancel colliding with that report bills the customer and the result is then discarded. Why every fix from the job's own fields fails. `JobStatusView.delete` guarantees a purged job cannot be resurrected BY relying on a late finalize deleting its own write. Storing the result instead removes that cleanup, and each attempt to rebuild the guarantee was holed: 1. Gate on `result_ref=""` -- a purged job could serve a late result, because DELETE blanks refs and leaves exactly the row the filter matched. 2. Also gate on `input_ref__gt=""` -- unsound. `delete_job_files` DELIBERATELY keeps a ref whose file delete failed, so TTL cleanup can retry it (execution_views.py:777). A non-blank `input_ref` therefore does not prove the job was never deleted, and a late callback still attaches. 3. Also re-read the row inside DELETE -- narrows the window, but leaves a storage orphan (cleanup clears a ref it never deleted, and later sweeps select only jobs that still have one, so extracted customer data stays in the bucket past its retention window), and leaves the cancellation webhook with no follow-up, so a webhook-driven caller never learns the answer became available. The common thread is that `result_ref`/`input_ref` carry three meanings -- never set, purged, and delete-failed-retry-later -- so purge state cannot be inferred from them. A real fix needs an explicit marker written unconditionally by DELETE and TTL cleanup, checked by the attach under a transaction, plus a follow-up notification. That is a migration, three call sites, transaction semantics and a webhook change on the cancel/delete/finalize contract: its own piece of work, not a rider on this PR. Same judgement as `hitl/cronjob.yaml` earlier in this review. Behaviour returns to discard-and-bill, which is at least self-consistent, and docs section 8 now carries the gap with all three failed approaches so the next person does not repeat them. The cloud side's log reverts to saying the result is discarded. Verification: agent_kv 304 passed; ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 55 +--- backend/agent_kv/execution_views_result.py | 29 +- backend/agent_kv/internal_views.py | 23 +- backend/agent_kv/models.py | 77 ------ .../agent_kv/tests/test_adapter_scoping.py | 256 ------------------ backend/agent_kv/tests/test_internal_views.py | 62 +---- backend/agent_kv/tests/test_job_views.py | 88 +----- docs/agent-kv-api.md | 68 +++-- 8 files changed, 54 insertions(+), 604 deletions(-) diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index b2731e3e8c..28b021a84d 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -659,24 +659,15 @@ def get(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): @AgentKVKeyValidator.validate_api_key def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): job = _get_job(agent_kv_key, job_id) - won = False - reread = False if job.status not in AgentKVJob.TERMINAL: # Cancel BEFORE deleting files: a still-running job would # otherwise keep running after its files are gone, and its # eventual finalize call would write a fresh result_ref onto a # job the caller already asked to delete -- resurrecting a # result they explicitly discarded. Terminalizing first closes - # that window. - # - # This used to add "and a finalize call that still lands late - # cleans up its own now-orphaned write". That is no longer the - # mechanism: a losing finalize on a CANCELLED row now ATTACHES its - # result instead of deleting it, so the purge is enforced by the - # unconditional `refresh_from_db()` before `delete_job_files` - # below, plus the `input_ref__gt=""` guard in - # `AgentKVJob.attach_result_to_cancelled`. Read both before - # changing either. + # that window; a finalize call that still lands late loses the + # terminal-state guard and, on the success path, cleans up its + # own now-orphaned write (FinalizeView, storage.delete_result_file). won = AgentKVJob.mark_terminal( job.id, job.organization_id, JobStatus.CANCELLED ) @@ -734,46 +725,6 @@ def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): # back. Deleting is still the caller's intent; it just has to # act on the refs that actually exist now. job.refresh_from_db() - reread = True - # Re-read before deleting files, not just on the lost-race branch above. - # - # This is what lets a cancelled job keep a result it paid for - # (`AgentKVJob.attach_result_to_cancelled`) without that result - # outliving a DELETE. The comment at the top of this method used to be - # able to say "a late finalize cleans up its own orphaned write" -- - # that stopped being true when a losing finalize started ATTACHING its - # result to a cancelled row instead of deleting it. So the cleanup has - # to happen here, from the row as it actually is now. - # - # Without this, the interleaving is: - # DELETE reads the row -> result_ref "" - # cancel wins, job CANCELLED - # late finalize attaches -> result_ref set, file written - # DELETE deletes files from its STALE copy -> input only - # DELETE blanks input_ref - # leaving a job the caller purged still serving a result. The re-read - # closes that: DELETE sees the attached ref and deletes that file too. - # - # Residual is a storage orphan only, never a disclosure: a finalize - # landing between this re-read and the blanking save sets a ref DELETE - # then clears without deleting its file. `attach_result_to_cancelled`'s - # `input_ref__gt=""` guard closes the other side -- once DELETE has - # blanked the input, no later finalize can attach at all. - # - # Scoped to the CANCELLED case, which is the only status a salvage can - # attach to (see that method's filter). A COMPLETED or FAILED row owns - # its outcome and nothing can write a result to it, so the extra query - # would be waste on the common path -- which is what the - # lost-race-only refresh above was originally reasoning about. - # - # `won` is in the condition and NOT just `job.status`: `mark_terminal` - # is a queryset UPDATE and does not touch the in-memory row, so after - # this request wins the cancel `job.status` is still RUNNING/PENDING. - # Testing only the attribute skipped the re-read on exactly the path - # that needs it -- and the DB-free unit tests agreed, because their - # mocked `mark_terminal` leaves the row unmutated in the same way. - if not reread and (won or job.status == JobStatus.CANCELLED): - job.refresh_from_db() # Blank only the refs whose files are confirmed gone, so a ref whose # delete failed survives as the handle TTL cleanup retries from. 204 # either way: the job IS terminal and the caller's intent is recorded, diff --git a/backend/agent_kv/execution_views_result.py b/backend/agent_kv/execution_views_result.py index 2233dfb720..5ec465a604 100644 --- a/backend/agent_kv/execution_views_result.py +++ b/backend/agent_kv/execution_views_result.py @@ -25,10 +25,7 @@ def result_payload(job) -> dict: - COMPLETED: ``{success, status, extractors, usage_summary}`` -- the stored engine result namespaced per extractor (spec §7.3), not the bare blob. - FAILED: ``{"success": False, "status": "failed", "error": }``. - - CANCELLED: ``{"success": False, "status": "cancelled"}`` -- unless the - cancel lost a race to a completed extraction, in which case the paid-for - result is served with ``success: True`` and ``status: "cancelled"`` (see - ``AgentKVJob.attach_result_to_cancelled``). + - CANCELLED: ``{"success": False, "status": "cancelled"}``. Callers are responsible for confirming ``job.status`` is terminal (and, for COMPLETED, that ``result_ref`` is non-blank and unexpired) before @@ -63,28 +60,4 @@ def result_payload(job) -> dict: "status": "failed", "error": job.error or "Job failed", } - # A cancel that lost the race to a completed extraction keeps its result - # (`AgentKVJob.attach_result_to_cancelled`): the engine had already spent - # and flushed the OCR/LLM usage, so the customer is billed either way, and - # discarding the answer was the strictly worse half of that trade. - # - # `status` stays `cancelled` -- the cancel WAS honoured and a client must - # still be able to see that -- but `success` is True and the result is - # served, because there is a real result and it was paid for. A client that - # branches only on `success` gets the data; one that branches on `status` - # sees the cancellation. Neither is misled. - # - # `result_ref` is blank for the ordinary cancel (nothing ever ran, or the - # run was stopped mid-flight), which keeps that path byte-identical. - if job.result_ref: - usage = job.usage_summary or {} - return { - "success": True, - "status": "cancelled", - "extractors": {job.extractor: read_result(job.result_ref)}, - "usage_summary": { - "total": usage, - "by_extractor": {job.extractor: usage}, - }, - } return {"success": False, "status": "cancelled"} diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index 29b1681591..de4583f5ff 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -198,25 +198,6 @@ class FinalizeView(APIView): def _complete(job, job_id, org_id: str, body) -> bool: """Persist the result and terminalize as COMPLETED. True if the guard won. - **A cancel that lost the race does not forfeit the result.** The window - is real: the executor can finish extraction -- spending OCR and LLM -- - and only then try its final stage report, which gets a ``noop`` because - a cancel terminalized the row in the meantime. The executor returns - success with the full output, this method writes it, and the guard below - correctly declines to change a terminal row's status. Deleting the file - at that point billed the customer for an answer and then destroyed it: - usage is flushed by the engine before any of this runs, so the money is - already spent either way, and `GET result` reported - ``{"success": false, "status": "cancelled"}`` with no indication that a - paid-for result had existed and been discarded. - - So a CANCELLED row keeps the result instead: ``result_ref`` and - ``usage_summary`` are attached WITHOUT touching ``status`` or - ``completed_at``, the cancel stays honoured, and ``result_payload`` - serves the data alongside ``status: "cancelled"``. The terminal guard - still owns the status transition -- this only adds a pointer to work - that was already paid for. - ``write_result`` unavoidably runs *before* the ``mark_terminal`` guard (the result has to exist to get a ``result_ref`` to pass in), so a guard-loss right after the write -- a concurrent cancel or duplicate @@ -235,9 +216,7 @@ def _complete(job, job_id, org_id: str, body) -> bool: ) if finalized: job.status = JobStatus.COMPLETED - elif not AgentKVJob.attach_result_to_cancelled( - job_id, org_id, result_ref=result_ref, usage_summary=body.get("usage_summary") - ): + else: delete_result_file(result_ref) return finalized diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index eaabd767f6..45df84d8dd 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -163,83 +163,6 @@ class Meta: models.Index(fields=["cleanup_failed_at", "expires_at"]), ] - @classmethod - def attach_result_to_cancelled( - cls, job_id, organization_id, *, result_ref, usage_summary=None - ) -> bool: - """Keep a paid-for result on a job that was CANCELLED mid-flight. - - The race this exists for: extraction completes -- OCR and LLM already - spent and flushed by the engine -- and only then does the executor's - final stage report land, by which time a cancel has terminalized the - row. The executor returns success with the full output, `FinalizeView` - writes it, and `mark_terminal` correctly declines to restatus a - terminal row. Before this, the written result was then DELETED: the - customer was billed for an answer and the answer was destroyed, with - `GET result` reporting only `{"success": false, "status": "cancelled"}`. - - Deliberately NOT part of `mark_terminal`, and deliberately unable to - change `status` or `completed_at`: the cancel stays won and stays - visible. This adds a pointer to work that was already paid for, nothing - more. `result_payload` then serves the data alongside - `status: "cancelled"`. - - Scoped to CANCELLED only. A COMPLETED or FAILED row already has its own - outcome and must not have a late callback's result grafted onto it -- - which is why this is not simply "attach to any terminal row". - - `result_ref=""` is the idempotency guard: a second at-least-once - callback finds the ref already set, matches zero rows, and its own - freshly written file is deleted by the caller rather than orphaned. - - **`input_ref__gt=""` is the purge guard, and it is the important one.** - `result_ref=""` alone cannot tell "cancelled, nothing stored yet" from - "cancelled and the caller explicitly DELETEd it" -- `JobDeleteView` - purges the files and blanks both refs, leaving exactly the row this - filter was matching. A late finalize would then have attached a newly - written result to a job whose data the caller had asked to be destroyed, - making it readable again. That is a deletion violation, not a - correctness nit, and it is strictly worse than the billing problem the - salvage exists to fix. - - `input_ref` discriminates them because a cancelled job KEEPS its staged - input (it rides `AGENT_KV_RESULT_TTL_DAYS`; only a winning finalize - drops it, via `_drop_staged_input`, which runs only when `finalized` is - True). So: - - * cancelled mid-flight, not deleted -> `input_ref` set -> salvage - * DELETEd by the caller -> `input_ref` blank -> refused - * swept by TTL cleanup -> `input_ref` blank -> refused - - Residual, stated rather than papered over: TTL cleanup can select this - row (its filter is `Q(input_ref__gt="") | Q(result_ref__gt="")`), read - an empty `result_ref`, and blank the refs after this update has set one - -- leaving the new result file orphaned in the bucket. A storage leak, - the same class the `JobDeleteView` refresh already documents, bounded by - the width of that one sweep, and strictly better than the alternative it - replaces, which was destroying a result the customer had paid for. - - Returns True when this call attached the result (and the caller must - therefore keep the file), False when it did not (caller deletes it). - """ - if not result_ref: - return False - fields = {"result_ref": result_ref} - if usage_summary is not None: - fields["usage_summary"] = usage_summary - return bool( - cls.objects.filter( - id=job_id, - organization_id=organization_id, - status=JobStatus.CANCELLED, - result_ref="", - # See the purge guard in the docstring: a blank `input_ref` - # means the caller DELETEd this job or TTL cleanup swept it. - # Re-attaching a result there resurrects purged data. - input_ref__gt="", - ).update(**fields) - ) - @classmethod def mark_terminal( cls, diff --git a/backend/agent_kv/tests/test_adapter_scoping.py b/backend/agent_kv/tests/test_adapter_scoping.py index 7a060c74eb..b5a6f59c44 100644 --- a/backend/agent_kv/tests/test_adapter_scoping.py +++ b/backend/agent_kv/tests/test_adapter_scoping.py @@ -431,259 +431,3 @@ def test_a_stale_in_memory_row_cannot_override_the_database(self): job.dispatched_at = None assert _never_dispatched(job) is False - - -class TestACancelThatLostTheRaceKeepsItsResult(TestCase): - """A cancel landing after extraction finished must not destroy the answer. - - The window is real and the money is already gone when it opens: the engine - flushes OCR and LLM usage before the executor's final stage report, so by - the time a cancel can collide with that report the customer has been - billed. `FinalizeView` then wrote the result, `mark_terminal` correctly - declined to restatus a terminal row, and the file was DELETED -- so - `GET result` returned `{"success": false, "status": "cancelled"}` with no - sign that a paid-for result had existed. - - DB-backed because the whole mechanism is a guarded UPDATE racing another - guarded UPDATE; a mock would just agree with whichever one I wrote. - """ - - def setUp(self): - self.org = Organization.objects.create( - display_name="Acme", organization_id="org_acme", name="acme" - ) - UserContext.set_organization_identifier(None) - - def _job(self, **kwargs): - from agent_kv.models import AgentKVJob, JobExtractor - - # `input_ref` defaults to a live staged input: a cancelled job keeps - # its input until TTL, and a BLANK one is the purge marker the salvage - # must refuse (see `test_a_purged_job_does_not_get_its_result_back`). - kwargs.setdefault("input_ref", "org/o/agent_kv/j/input.pdf") - job = AgentKVJob( - organization_id=self.org.id, extractor=JobExtractor.TABLE, **kwargs - ) - job.save() - return job - - def test_the_result_is_attached_to_a_cancelled_job(self): - from agent_kv.models import AgentKVJob, JobStatus - - job = self._job(status=JobStatus.CANCELLED) - - attached = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/1.json", usage_summary={"pages": 3} - ) - job.refresh_from_db() - - assert attached is True - assert job.result_ref == "r/1.json" - assert job.usage_summary == {"pages": 3} - - def test_it_never_changes_status_or_completed_at(self): - """The cancel stays won, and stays visible. - - If this ever starts flipping `status`, a cancelled job would read as - completed and the guard `mark_terminal` exists to enforce would have - been bypassed by the back door. - """ - from agent_kv.models import AgentKVJob, JobStatus - from django.utils import timezone - - stamped = timezone.now() - job = self._job(status=JobStatus.CANCELLED, completed_at=stamped) - - AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/2.json" - ) - job.refresh_from_db() - - assert job.status == JobStatus.CANCELLED - assert job.completed_at == stamped - - def test_a_completed_or_failed_job_is_left_alone(self): - """Scoped to CANCELLED on purpose. - - A COMPLETED or FAILED row already has its own outcome; grafting a late - callback's result onto it would overwrite a real one (COMPLETED) or - invent one for a run that failed. - """ - from agent_kv.models import AgentKVJob, JobStatus - - for status in (JobStatus.COMPLETED, JobStatus.FAILED): - job = self._job(status=status) - attached = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/nope.json" - ) - job.refresh_from_db() - assert attached is False, status - assert job.result_ref == "", status - - def test_a_second_callback_does_not_overwrite_the_first(self): - """At-least-once delivery means this runs twice. - - The second call must report False so its caller deletes its own freshly - written file rather than orphaning it -- `result_ref=""` in the filter - is what makes that happen. - """ - from agent_kv.models import AgentKVJob, JobStatus - - job = self._job(status=JobStatus.CANCELLED) - - first = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/first.json" - ) - second = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="r/second.json" - ) - job.refresh_from_db() - - assert (first, second) == (True, False) - assert job.result_ref == "r/first.json" - - def test_a_blank_ref_is_refused_so_the_caller_keeps_its_delete(self): - from agent_kv.models import AgentKVJob, JobStatus - - job = self._job(status=JobStatus.CANCELLED) - - assert ( - AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref="") - is False - ) - - def test_another_org_cannot_attach_a_result(self): - from agent_kv.models import AgentKVJob, JobStatus - - other = Organization.objects.create( - display_name="Other", organization_id="org_other", name="other" - ) - job = self._job(status=JobStatus.CANCELLED) - - assert ( - AgentKVJob.attach_result_to_cancelled( - job.id, other.id, result_ref="r/x.json" - ) - is False - ) - - def test_the_result_payload_serves_it_while_still_reporting_cancelled(self): - """The read half. `status` must stay `cancelled` -- the cancel WAS - honoured and a client has to be able to see that -- while `success` is - True because there is a real, paid-for result to hand over. - """ - from agent_kv.execution_views_result import result_payload - from agent_kv.models import JobStatus - from unittest import mock - - job = self._job(status=JobStatus.CANCELLED, result_ref="r/3.json") - - with mock.patch( - "agent_kv.execution_views_result.read_result", - return_value={"tables": [], "row_count": 0}, - ): - payload = result_payload(job) - - assert payload["status"] == "cancelled" - assert payload["success"] is True - assert payload["extractors"]["table"] == {"tables": [], "row_count": 0} - - def test_an_ordinary_cancel_is_byte_identical_to_before(self): - """The common path -- cancelled mid-run, nothing computed -- must not - change shape just because the salvage path exists. - """ - from agent_kv.execution_views_result import result_payload - from agent_kv.models import JobStatus - - job = self._job(status=JobStatus.CANCELLED) - - assert result_payload(job) == {"success": False, "status": "cancelled"} - - -class TestThePurgeGuardOnTheSalvage(TestCase): - """A result the caller DELETEd must not come back. - - `result_ref=""` alone cannot distinguish "cancelled, nothing stored yet" - from "cancelled and explicitly purged": `JobDeleteView` deletes the files - and blanks BOTH refs, which is exactly the row the salvage filter matched. - A late finalize would then re-attach a newly written result to a job whose - data the caller asked to have destroyed. - - `input_ref` is the discriminator: a cancelled job keeps its staged input - (only a WINNING finalize drops it, and the salvage path is by definition a - losing one), while DELETE and TTL cleanup both blank it. - """ - - def setUp(self): - self.org = Organization.objects.create( - display_name="Acme", organization_id="org_acme", name="acme" - ) - UserContext.set_organization_identifier(None) - - def _cancelled(self, **kwargs): - from agent_kv.models import AgentKVJob, JobExtractor, JobStatus - - job = AgentKVJob( - organization_id=self.org.id, - extractor=JobExtractor.TABLE, - status=JobStatus.CANCELLED, - **kwargs, - ) - job.save() - return job - - def test_a_purged_job_does_not_get_its_result_back(self): - """The deletion violation. Both refs blank = the caller purged it.""" - from agent_kv.models import AgentKVJob - - job = self._cancelled(input_ref="", result_ref="") - - attached = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="org/o/agent_kv/j/result.json" - ) - job.refresh_from_db() - - assert attached is False, ( - "re-attached a result to a job the caller DELETEd; purged data " - "must not become readable again" - ) - assert job.result_ref == "" - - def test_a_live_cancelled_job_still_gets_its_result(self): - """The other half: the guard must not refuse the case it exists for.""" - from agent_kv.models import AgentKVJob - - job = self._cancelled(input_ref="org/o/agent_kv/j/input.pdf", result_ref="") - - attached = AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="org/o/agent_kv/j/result.json" - ) - job.refresh_from_db() - - assert attached is True - assert job.result_ref == "org/o/agent_kv/j/result.json" - - def test_a_ttl_swept_job_does_not_get_a_result_grafted_on(self): - """TTL cleanup blanks both refs, same shape as DELETE. - - Re-attaching there would put a result on a job past its retention - window, which `JobResultView`'s expiry gate would then 404 anyway -- - but the file would exist, unreferenced-but-reachable, after the - retention window the customer was promised. - """ - from agent_kv.models import AgentKVJob - from django.utils import timezone - from datetime import timedelta - - job = self._cancelled( - input_ref="", - result_ref="", - expires_at=timezone.now() - timedelta(days=1), - ) - - assert ( - AgentKVJob.attach_result_to_cancelled( - job.id, self.org.id, result_ref="org/o/agent_kv/j/late.json" - ) - is False - ) diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py index 42d74760e3..20cba4c677 100644 --- a/backend/agent_kv/tests/test_internal_views.py +++ b/backend/agent_kv/tests/test_internal_views.py @@ -412,21 +412,13 @@ def test_finalize_success_deletes_input_and_blanks_ref( # cancel raced in between the read and mark_terminal's guarded UPDATE) -- # the just-written result file has no ref anywhere pointing at it, so it's # cleaned up immediately rather than orphaned forever. -# `attach_result_to_cancelled` is patched to False here, which is what the real -# DB returns for these jobs: its filter is `status=CANCELLED, result_ref=""`, -# and the rows in these two cases are RUNNING and COMPLETED. Without the patch -# the mocked `AgentKVJob.objects` makes its `.update()` return a Mock, which is -# truthy, so the salvage would appear to win and the orphan delete these tests -# exist to assert would silently stop being called. The salvage path itself is -# covered DB-backed in `test_adapter_scoping.py`. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @mock.patch.object(iv, "delete_result_file") -@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) @mock.patch.object(iv, "write_result") @mock.patch.object(AgentKVJob, "objects") def test_finalize_success_guard_loss_deletes_orphaned_result( - m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release + m_jobs, m_write, m_mark_terminal, m_delete_result, m_release ): job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") m_jobs.filter.return_value.first.return_value = job @@ -446,52 +438,6 @@ def test_finalize_success_guard_loss_deletes_orphaned_result( m_delete_result.assert_called_once_with("org/o/agent_kv/j/result.json") -# (7c-ii) the case (7c) above must NOT catch: the guard was lost to a CANCEL, -# and the run had already finished and been billed. Deleting the result there -# charged the customer for an answer and then destroyed it, with `GET result` -# reporting only `{"success": false, "status": "cancelled"}`. The result is -# salvaged onto the cancelled row instead. -@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") -@mock.patch.object(iv, "delete_result_file") -@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=True) -@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) -@mock.patch.object(iv, "write_result") -@mock.patch.object(AgentKVJob, "objects") -def test_finalize_guard_lost_to_a_cancel_salvages_the_paid_for_result( - m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release -): - job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") - m_jobs.filter.return_value.first.return_value = job - m_write.return_value = "org/o/agent_kv/j/result.json" - - job_id = uuid.uuid4() - resp = iv.FinalizeView.as_view()( - _post( - "/x", - { - "org_id": "org1", - "success": True, - "result": {"tables": []}, - "usage_summary": {"pages": 3}, - }, - ), - job_id=job_id, - ) - - assert resp.status_code == 200 - # Still not "finalized": the cancel won the status, and this must not - # pretend otherwise. - assert resp.data["finalized"] is False - m_salvage.assert_called_once() - kwargs = m_salvage.call_args.kwargs - assert kwargs["result_ref"] == "org/o/agent_kv/j/result.json" - assert kwargs["usage_summary"] == {"pages": 3} - assert not m_delete_result.called, ( - "the result was deleted after the customer had already been billed " - "for it; a cancelled job keeps a result it paid for" - ) - - # (7d) success finalize that WINS the guard never deletes the result it just # wrote -- only a guard-LOSS orphan triggers cleanup. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @@ -526,14 +472,10 @@ def test_finalize_success_guard_win_does_not_delete_result( # result.json path let the loser delete the winner's live result). @mock.patch.object(storage, "FileSystem") @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") -# False for the same reason as the guard-loss case above: the row the loser -# finds is COMPLETED, not CANCELLED, so the real filter matches nothing and the -# loser must still delete the file it just wrote. -@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", side_effect=[True, False]) @mock.patch.object(AgentKVJob, "objects") def test_finalize_duplicate_success_race_loser_deletes_only_its_own_result( - m_jobs, m_mark, m_salvage, m_release, m_fs + m_jobs, m_mark, m_release, m_fs ): # Two separate reads that both observed the job as still RUNNING (the race # window before either guarded UPDATE ran). diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index ae338eb5e6..5d61eb6356 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -412,19 +412,14 @@ def test_delete_calls_delete_job_files_and_blanks_refs( # files are deleted -- a still-running job that finalizes late would # otherwise write a fresh result onto a job the caller just deleted. # --------------------------------------------------------------------------- -# `refresh_from_db` is patched because DELETE now re-reads the row before -# deleting files whenever it has just cancelled the job -- a late finalize can -# attach a result to a CANCELLED row, and deleting from the pre-cancel snapshot -# would leave that result on a purged job. This suite is DB-free, so the real -# query raises; the behaviour itself is asserted in -# `test_delete_DOES_refresh_when_it_wins_the_cancel`. -@mock.patch.object(AgentKVJob, "refresh_from_db") @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files") @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_on_running_job_cancels_before_deleting_files(m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_refresh): +def test_delete_on_running_job_cancels_before_deleting_files( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save +): m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.RUNNING) job.organization_id = "org1" @@ -452,20 +447,15 @@ def test_delete_on_running_job_cancels_before_deleting_files(m_keys, m_jobs, m_m # leaked a slot until the 6h TTL, and enough of them exhaust the org's # allowance and start rejecting new submits. # --------------------------------------------------------------------------- -# `refresh_from_db` is patched because DELETE now re-reads the row before -# deleting files whenever it has just cancelled the job -- a late finalize can -# attach a result to a CANCELLED row, and deleting from the pre-cancel snapshot -# would leave that result on a purged job. This suite is DB-free, so the real -# query raises; the behaviour itself is asserted in -# `test_delete_DOES_refresh_when_it_wins_the_cancel`. -@mock.patch.object(AgentKVJob, "refresh_from_db") @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_on_a_running_job_does_NOT_release_the_slot(m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_refresh): +def test_delete_on_a_running_job_does_NOT_release_the_slot( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release +): """Inverted: this used to assert the release, which was the bug. Nothing revokes a running executor -- `job.task_id` is written at dispatch @@ -493,13 +483,6 @@ def test_delete_on_a_running_job_does_NOT_release_the_slot(m_keys, m_jobs, m_mar ) -# `refresh_from_db` is patched because DELETE now re-reads the row before -# deleting files whenever it has just cancelled the job -- a late finalize can -# attach a result to a CANCELLED row, and deleting from the pre-cancel snapshot -# would leave that result on a purged job. This suite is DB-free, so the real -# query raises; the behaviour itself is asserted in -# `test_delete_DOES_refresh_when_it_wins_the_cancel`. -@mock.patch.object(AgentKVJob, "refresh_from_db") @mock.patch.object(ev, "dispatch_cancelled_webhook") @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @@ -507,7 +490,9 @@ def test_delete_on_a_running_job_does_NOT_release_the_slot(m_keys, m_jobs, m_mar @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_on_a_never_dispatched_job_DOES_release_the_slot(m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook, m_refresh): +def test_delete_on_a_never_dispatched_job_DOES_release_the_slot( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook +): """The case the release exists for, and the only one. A job cancelled before dispatch gets no finalize callback, and the sweep's @@ -704,55 +689,17 @@ def _record(j): @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_DOES_refresh_when_it_wins_the_cancel( +def test_delete_does_not_refresh_when_it_wins_the_terminal_race( m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release ): - """Winning the cancel is exactly when the re-read is needed. - - This test used to assert the opposite, on the reasoning that "winning means - nothing else wrote to the row, so the in-memory copy is current". That - stopped being true when a losing finalize started ATTACHING its result to a - CANCELLED row instead of deleting it: after this request wins the cancel, a - late finalize can still write a `result_ref`, and deleting files from the - stale copy would leave that result on a job the caller purged. - - Note `won` is what drives the re-read, not `job.status`. `mark_terminal` is - a queryset UPDATE and never touches the in-memory row, so `job.status` is - still RUNNING here -- a condition testing only the attribute would skip the - re-read on precisely this path, and this mock reproduces that faithfully - (which is how the first cut of the fix passed while being wrong). + """Winning means nothing else wrote to the row, so the in-memory copy is + current and the extra query would be waste on the common path. """ m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.RUNNING) job.organization_id = "org1" m_jobs.get.return_value = job - with mock.patch.object(AgentKVJob, "refresh_from_db") as m_refresh: - resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) - - assert resp.status_code == 204 - assert m_refresh.called, ( - "DELETE deleted files from a pre-cancel snapshot; a result attached by " - "a late finalize would survive the purge" - ) - - -@mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") -@mock.patch.object(AgentKVJob, "save") -@mock.patch.object(ev, "delete_job_files", return_value=["input_ref"]) -@mock.patch.object(AgentKVJob, "objects") -@mock.patch.object(AgentKVKey, "objects") -def test_delete_on_a_completed_job_skips_the_extra_query( - m_keys, m_jobs, m_delete_files, m_save, m_release -): - """The other side: nothing can attach a result to a COMPLETED or FAILED - row, so the re-read would be waste on the common path. - """ - m_keys.get.return_value = kv_key() - job = AgentKVJob(status=JobStatus.COMPLETED) - job.organization_id = "org1" - m_jobs.get.return_value = job - with mock.patch.object(AgentKVJob, "refresh_from_db") as m_refresh: resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) @@ -816,13 +763,6 @@ def test_cancel_that_loses_does_not_queue_a_webhook( assert not m_hook.called -# `refresh_from_db` is patched because DELETE now re-reads the row before -# deleting files whenever it has just cancelled the job -- a late finalize can -# attach a result to a CANCELLED row, and deleting from the pre-cancel snapshot -# would leave that result on a purged job. This suite is DB-free, so the real -# query raises; the behaviour itself is asserted in -# `test_delete_DOES_refresh_when_it_wins_the_cancel`. -@mock.patch.object(AgentKVJob, "refresh_from_db") @mock.patch.object(ev, "dispatch_cancelled_webhook") @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @@ -830,7 +770,9 @@ def test_cancel_that_loses_does_not_queue_a_webhook( @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_that_cancels_a_running_job_also_queues_the_webhook(m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook, m_refresh): +def test_delete_that_cancels_a_running_job_also_queues_the_webhook( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook +): """DELETE on a non-terminal job cancels it first; that cancellation is just as terminal, and just as invisible to the caller without this. """ diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index c38d4f5a3b..431910cfeb 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -483,24 +483,7 @@ Re-readable until `expires_at` (D11), plus explicit `DELETE`. finalize call sent). - **`FAILED`**: `200` with `{"success": false, "status": "failed", "error": ""}`. -- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}` — the - ordinary case, where nothing ran or the run was stopped mid-flight. - - **Exception: a cancel that lost a race to a finished extraction keeps its - result.** The window is narrow but the money is already gone when it opens: - the engine flushes OCR and LLM usage *before* the executor's final stage - report, so a cancel colliding with that report arrives after the customer - has been billed. Such a job returns `200` with - `{"success": true, "status": "cancelled", "extractors": {...}, "usage_summary": {...}}` - — the full result, with `status` still `cancelled` because the cancel *was* - honoured. Branch on `success` for "is there data"; branch on `status` for - "was it cancelled". Previously the result was written and then deleted, so - the caller paid for an answer and received - `{"success": false, "status": "cancelled"}` with no indication it had ever - existed. The job is **not** reported as `completed` — the cancel still owns - the status — and the result rides the same `AGENT_KV_RESULT_TTL_DAYS` window - as any other, after which this degrades back to the ordinary shape. It is - also refused outright if you called `DELETE` on the job: the purge wins, and - a late result is not resurrected. +- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}`. **Calculation result shape** (when `calculations` is supplied and `AGENT_KV_CALCULATIONS_ENABLED=true`): a `COMPLETED` result adds these fields to the @@ -653,24 +636,37 @@ the engine's own progress points (rate-limited, so detection can lag by a few se and the job then terminates as `cancelled`. Work already done is still billed and metered; work not yet started is not. -**A job that finishes before the cancel is noticed KEEPS its result.** This used -to say the result was "discarded on arrival", which was accurate and was the -problem: the engine flushes OCR and LLM usage before the executor's final stage -report, so the customer had already been billed for an answer that was then -written and deleted. Such a job stays `cancelled` — the cancel is honoured and -visible — but `GET /agent-kv/{job_id}/result/` serves the result with -`success: true` (see [§5](#7-get-result)). Fetch it; you have paid for it. - -Two cases where it genuinely is gone, both deliberate: - -- You called `DELETE /agent-kv/{job_id}/` — the purge wins, by two independent - mechanisms: `DELETE` re-reads the row immediately before deleting files, so a - result that landed mid-flight is deleted along with everything else; and once - it has blanked the input ref, no later callback can attach one at all. A - result is never resurrected on a job you purged. -- The job passed `AGENT_KV_RESULT_TTL_DAYS` and TTL cleanup swept it, after - which the endpoint reports the ordinary - `{"success": false, "status": "cancelled"}`. +> **Known gap: a job that finishes before the cancel is noticed has its result +> discarded on arrival, and you are still billed for it.** The engine flushes OCR +> and LLM usage *before* the executor's final stage report, so a cancel colliding +> with that report lands after the work is paid for. `FinalizeView` then writes +> the result, its `mark_terminal` guard correctly declines to restatus a terminal +> row, and the file is deleted — `GET result` reports +> `{"success": false, "status": "cancelled"}` with no indication that a +> fully-computed answer existed. +> +> This is not an oversight, it is an unresolved trade, and keeping the result +> was tried three times and reverted each time. The obstacle is that +> `DELETE /agent-kv/{job_id}/` guarantees a purged job cannot be resurrected +> precisely BY relying on a late finalize deleting its own write (see the +> comment opening `JobStatusView.delete`). Storing the result instead removes +> that cleanup, and every attempt to re-establish the guarantee from the job's +> own fields failed review: +> +> - Gating on `result_ref` alone let a purged job serve a late result. +> - Gating additionally on a non-blank `input_ref` did not help: `DELETE` +> deliberately KEEPS a ref whose file delete failed, so that it can be +> retried — meaning a non-blank `input_ref` does not prove the job was never +> deleted. +> - Re-reading the row inside `DELETE` narrowed the window but left a storage +> orphan (cleanup can clear a ref it never deleted, and later sweeps only +> select jobs that still have one), and left the cancellation webhook +> un-followed-up, so a webhook-driven caller never learns the answer arrived. +> +> Closing it needs an explicit "files purged" marker that `DELETE`, TTL cleanup +> and finalize all agree on, written unconditionally rather than inferred from +> refs, plus a follow-up notification. Tracked separately. The window is narrow, +> and the caller did ask to cancel. - Won the race (job was non-terminal): `200` `{"status": "cancelled"}`. - Already terminal: `409` `{"status": ""}`, lowercased (e.g. From ae90caf3e0e75631c6ffbdee931c27dfb3379e72 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 10:03:21 +0530 Subject: [PATCH 28/54] UN-4232 [FIX] Close the cancelled-after-completion result gap for good Fourth attempt, and the first that addresses the defect rather than a window next to it. Reinstates the intent of 785cee5d6 / c0726bae3 / d747a3260 (reverted by ad218c8f0, c01059d0f) on a different foundation. The defect: the engine flushes OCR and LLM usage BEFORE the executor's final stage report, so a cancel colliding with that report lands after the customer has been billed. The answer was then destroyed -- `GET result` reported `{"success": false, "status": "cancelled"}` and one INFO line. **All three previous attempts salvaged in the wrong place.** They patched `FinalizeView._complete`, which `post` only reaches when the row it read was still non-terminal -- a window of microseconds. The real race is wider and earlier: the executor learns of a cancel FROM its final stage report, so by the time the callback worker calls finalize the row has read CANCELLED for a whole round trip, `post` short-circuits on "already terminal", and the output is dropped without even being written. `FinalizeView._salvage` handles that case (success + CANCELLED only); `_complete`'s attach still handles the narrow one. Both go through one `_attach_or_discard`, so a refused attach always deletes the file that attempt wrote. Why the purge guarantee needed rebuilding, since that is the useful part. `JobStatusView.delete` guaranteed a purged job could not be resurrected BY RELYING on a late finalize deleting its own orphaned write -- stated in the comment opening that method. Keeping the result removes exactly that cleanup, and every attempt to rebuild the guarantee from the job's own fields was holed: 1. `result_ref=""` -- DELETE blanks the refs and leaves exactly that row. 2. plus `input_ref__gt=""` -- unsound. `delete_job_files` DELIBERATELY keeps a ref whose delete failed so TTL cleanup can retry, so a non-blank `input_ref` does not prove the job was never purged. 3. plus a re-read inside DELETE -- narrowed the window, left a storage orphan and left the cancellation webhook un-followed-up. `result_ref`/`input_ref` carry three meanings -- never set, purged, and delete-failed-retry-later -- so purge state cannot be inferred from them. It is now recorded instead: * `files_purged` (migration 0008), written UNCONDITIONALLY by `JobStatusView.delete` and `run_ttl_cleanup` before either attempts a delete, so a failed delete cannot leave it False. It records intent, not outcome. * Both of those claim it through `AgentKVJob.mark_files_purged`, which sets the marker and returns the row read under `SELECT ... FOR UPDATE`; `attach_result_to_cancelled` takes the same lock. So an attach either committed before the claim's read (its ref is in the returned row and its file is deleted with everything else) or runs after it (matches nothing, caller deletes its own write). No third interleaving, so no storage orphan. * DELETE's re-read is unconditional. `mark_terminal` is a queryset UPDATE that never mutates the in-memory row, so any condition built from `job.status` skips the re-read on precisely the path that needs it -- and the DB-free tests reproduce the un-mutated row faithfully, so they would agree with the mistake. `JobResultView` (and submit's sync-wait branch) now refuse a purged job on the marker. That also closes a PRE-EXISTING hole: a DELETE whose result-file delete failed kept `result_ref` set as the TTL retry handle, and this endpoint went on reading that very file. No ref-based check can see that case. A `result_available: true` follow-up webhook is sent when a result is attached after a cancellation notification already went out, since a webhook-driven caller has no reason to poll a job they were told was cancelled. One additive key, on exactly one notification, documented in docs section 8 along with the fact that nothing orders it against the cancellation POST. `mark_terminal` remains the only way to reach a terminal state: the attach cannot touch `status` or `completed_at`, a cancel that won keeps `status: cancelled`, and `_drop_staged_input` stays gated on a won guard so a cancelled job's input still rides TTL. On the product question: serving the result is the right half of the trade, not "discard but don't bill". OCR page usage is posted per-OCR-call by the SDK (`Audit.push_page_usage_data` inside `X2Text.process`) and is in `usage_v2` before a cancel can be noticed, with no reversal facility; only LLM usage is deferrable. Withholding billing would leave the customer charged for the OCR, uncharged for the LLM, still without an answer, and contradicting section 6's "work already done is still billed and metered". Tests: 34 new, 25 of them DB-backed in `test_cancelled_result_salvage.py` (the salvage, the purge guard against all three failed discriminators, the marker, the finalize callback end to end, the TTL sweep, and what the result endpoint serves). Ten mutations each fail a test that names the consequence: removing either salvage route, widening it past CANCELLED, letting it drop the staged input, removing `files_purged` from the attach filter or the result view, removing the claim from DELETE or TTL cleanup, removing `select_for_update` from either side, and removing the follow-up webhook. The two DB-free tests that patch `AgentKVJob.objects` pin the attach to False so their orphan-delete assertions stay live. Verification: agent_kv 338 passed, with migrations applied and with `--no-migrations`; backend unit 1420 / integration 571; workers unit 1491; `makemigrations --check` clean; single migration leaf everywhere; ruff 0.3.4 check + format clean. Companion: Zipstack/unstract-cloud#1828 Co-Authored-By: Claude Opus 5 --- backend/agent_kv/dispatch.py | 8 + backend/agent_kv/execution_views.py | 119 +++- backend/agent_kv/execution_views_result.py | 33 +- backend/agent_kv/internal_views.py | 159 ++++- backend/agent_kv/maintenance.py | 25 +- .../0008_agentkvjob_files_purged.py | 44 ++ backend/agent_kv/models.py | 171 ++++- .../tests/test_cancelled_result_salvage.py | 629 ++++++++++++++++++ backend/agent_kv/tests/test_internal_views.py | 209 +++++- backend/agent_kv/tests/test_job_views.py | 132 ++-- backend/agent_kv/tests/test_sweeps.py | 35 +- docs/agent-kv-api.md | 207 ++++-- workers/ide_callback/agent_kv_tasks.py | 58 +- .../tests/test_agent_kv_callbacks.py | 125 ++++ workers/shared/api/internal_client.py | 8 +- 15 files changed, 1822 insertions(+), 140 deletions(-) create mode 100644 backend/agent_kv/migrations/0008_agentkvjob_files_purged.py create mode 100644 backend/agent_kv/tests/test_cancelled_result_salvage.py diff --git a/backend/agent_kv/dispatch.py b/backend/agent_kv/dispatch.py index 7f6cb4276f..e61916475b 100644 --- a/backend/agent_kv/dispatch.py +++ b/backend/agent_kv/dispatch.py @@ -34,6 +34,14 @@ def dispatch_cancelled_webhook(job) -> None: paths mutually exclusive: a cancel that lost means a finalize won and will send, and a cancel that won means no fresh finalize can. + One thing a late callback CAN now add, and it is additive to this rather + than a competitor for it: if the cancelled job's extraction had already + finished, its result is attached to the row and a ``result_available`` + follow-up is sent (``ide_callback.agent_kv_tasks._maybe_webhook``), because + this notification has already told the caller the job was cancelled and + nothing else would ever correct that. The TERMINAL notification is still + this one, sent exactly once. + Best-effort by design. The job is already cancelled and the caller already has their 200; failing their request because a notification could not be QUEUED would be the wrong trade, so this logs and returns rather than diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index 28b021a84d..908b2865c7 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -260,6 +260,15 @@ def _sync_wait_response(job, wait: float) -> Response | None: while time.monotonic() < deadline: job.refresh_from_db() if job.status in AgentKVJob.TERMINAL: + # A concurrent `DELETE /agent-kv/{job_id}` while this request is + # still waiting purges the job, and this is the second reader of a + # stored result -- so it owes the same answer `JobResultView` gives + # a purged job, for the same reason. Without it, the one caller who + # cannot poll (they are still holding the submit open) is the one + # caller who could still be handed data they asked to have + # destroyed. + if job.files_purged: + raise JobNotFound() from agent_kv.execution_views_result import result_payload return Response(result_payload(job), status=200) @@ -665,9 +674,18 @@ def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): # eventual finalize call would write a fresh result_ref onto a # job the caller already asked to delete -- resurrecting a # result they explicitly discarded. Terminalizing first closes - # that window; a finalize call that still lands late loses the - # terminal-state guard and, on the success path, cleans up its - # own now-orphaned write (FinalizeView, storage.delete_result_file). + # that window. + # + # This comment used to continue "and a finalize call that still + # lands late loses the terminal-state guard and cleans up its own + # now-orphaned write". That is NO LONGER the mechanism, and it is + # worth saying why: a losing finalize on a CANCELLED row now + # ATTACHES its result rather than deleting it (the customer has + # already been billed for it -- see + # `AgentKVJob.attach_result_to_cancelled`), so delegating the purge + # to that cleanup would silently stop working. The purge is + # enforced HERE instead, by `AgentKVJob.mark_files_purged` below. + # Read both before changing either. won = AgentKVJob.mark_terminal( job.id, job.organization_id, JobStatus.CANCELLED ) @@ -707,34 +725,68 @@ def delete(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): ) # This request owns the terminal notification: no fresh finalize # can follow a cancel that won, so nothing else will send it. + # (A later finalize that SALVAGES a finished extraction's result + # onto this now-cancelled row sends a `result_available` + # follow-up -- see `_maybe_webhook` in the callback worker. That + # is an addition to this notification, not a competitor for it.) dispatch_cancelled_webhook(job) - else: - # Lost the race: a finalize or cancel terminalized this job - # between our read above and the guarded UPDATE. `job` is now - # STALE, and `result_ref` is the field that matters -- a winning - # finalize has just written one. - # - # Without this refresh the cleanup below runs against the stale - # copy, where `result_ref` is still "". `delete_job_files` - # reports an already-empty ref as "cleared" (nothing to delete), - # so the save then writes "" OVER the winner's real ref. The job - # stays COMPLETED, its result 404s (`JobResultView` treats - # COMPLETED-without-a-ref as swept), and the object is orphaned - # in the bucket with nothing left pointing at it -- TTL cleanup - # selects on `result_ref > ""`, so a blanked row never comes - # back. Deleting is still the caller's intent; it just has to - # act on the refs that actually exist now. - job.refresh_from_db() + # Claim the purge, then act on the row as it ACTUALLY IS -- not on the + # copy read at the top of this method. + # + # Unconditional, on every DELETE, for two independent reasons. + # + # 1. `files_purged` records the caller's intent, so it must be written + # whatever the row's status and whatever the deletes below go on to + # do. It is what `attach_result_to_cancelled` and `JobResultView` + # consult to refuse a purged job, and inferring it later from the ref + # columns is exactly the mistake that sank three earlier attempts at + # this fix (see the field's comment in models.py). + # + # 2. The row must be re-read, for the same reason the lost-race branch + # above used to re-read it, plus a new one: + # + # * lost race -- a finalize or cancel terminalized this job between + # the read above and the guarded UPDATE, so `result_ref` on the + # local copy is still "". `delete_job_files` reports an + # already-empty ref as "cleared" (nothing to delete), so the save + # would write "" OVER the winner's real ref: its result 404s and + # the object is orphaned with nothing pointing at it, since TTL + # cleanup selects on `result_ref > ""` and a blanked row never + # comes back. + # * won the cancel -- a late finalize can still ATTACH a result to + # the row we just cancelled. Deleting from the pre-cancel snapshot + # would leave that result in place, readable, on a job the caller + # purged. + # + # The second case is why this is not conditioned on `won`: + # `mark_terminal` is a queryset UPDATE and never touches the + # in-memory row, so after winning a cancel `job.status` still reads + # RUNNING. Any condition built from that attribute skips the re-read + # on precisely the path that needs it -- and the DB-free tests in + # this suite reproduce the un-mutated row faithfully, so they would + # agree with the mistake. Not having a condition cannot get that + # wrong. + # + # `mark_files_purged` takes the row lock for its marker write, so a + # concurrent attach either committed before this read (its ref is in + # `purged` and its file is deleted below) or is refused outright. The + # `or job` fallback is for a row that no longer exists at all, which + # the retained-audit-trail design does not produce. + purged = AgentKVJob.mark_files_purged(job.id, job.organization_id) or job # Blank only the refs whose files are confirmed gone, so a ref whose # delete failed survives as the handle TTL cleanup retries from. 204 # either way: the job IS terminal and the caller's intent is recorded, # and a sync 5xx here would only invite a retry of a DELETE that already # did everything it could. - cleared = delete_job_files(job) + # + # The claim above is the opposite case and is deliberately NOT guarded: + # if it raises, nothing has been destroyed and nothing recorded, so the + # 500 is accurate and a retry is exactly the right response. + cleared = delete_job_files(purged) if cleared: for field in cleared: - setattr(job, field, "") - job.save(update_fields=cleared) + setattr(purged, field, "") + purged.save(update_fields=cleared) return Response(status=204) @@ -755,8 +807,19 @@ def get(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): # A job's row outlives its result by design (audit trail after TTL # cleanup blanks the refs) -- expired or a COMPLETED job whose # result was already swept both mean "nothing left to serve". - if (job.expires_at and job.expires_at < timezone.now()) or ( - job.status == JobStatus.COMPLETED and not job.result_ref + # + # `files_purged` is the third of those, and it is NOT redundant with the + # blank-ref check: `delete_job_files` deliberately KEEPS a ref whose + # object delete failed, so that TTL cleanup has a handle to retry from. + # Without this clause a DELETE whose result-file delete failed left the + # ref set and the object in the bucket -- and this endpoint went on + # serving it, reading the very file the caller asked to have destroyed. + # A ref-based check cannot see that case at all, which is the whole + # reason purge state is recorded on its own column. + if ( + job.files_purged + or (job.expires_at and job.expires_at < timezone.now()) + or (job.status == JobStatus.COMPLETED and not job.result_ref) ): raise JobNotFound() return Response(result_payload(job), status=200) @@ -798,6 +861,12 @@ def post(self, request, *args, job_id=None, agent_kv_key=None, **kwargs): # reaches finalize -- so this path has to send it. Guarded on `won` # so a cancel that LOST leaves the notification to the finalize that # beat it, and the caller is told exactly once. + # + # "Once" is about the TERMINAL notification. A finalize arriving + # afterwards with a finished extraction's result attaches it to this + # cancelled row and sends a `result_available` follow-up, because + # this POST has already told the caller the job was cancelled and + # nothing else would ever correct that. dispatch_cancelled_webhook(job) return Response({"status": "cancelled"}, status=200) # Lowercased for cross-endpoint consistency (spec §7.2). diff --git a/backend/agent_kv/execution_views_result.py b/backend/agent_kv/execution_views_result.py index 5ec465a604..f21cc531d9 100644 --- a/backend/agent_kv/execution_views_result.py +++ b/backend/agent_kv/execution_views_result.py @@ -25,7 +25,11 @@ def result_payload(job) -> dict: - COMPLETED: ``{success, status, extractors, usage_summary}`` -- the stored engine result namespaced per extractor (spec §7.3), not the bare blob. - FAILED: ``{"success": False, "status": "failed", "error": }``. - - CANCELLED: ``{"success": False, "status": "cancelled"}``. + - CANCELLED: ``{"success": False, "status": "cancelled"}`` -- unless the + cancel lost a race to an extraction that had already finished and been + billed, in which case the paid-for result is served with ``success: True`` + alongside ``status: "cancelled"`` (see + ``AgentKVJob.attach_result_to_cancelled``). Callers are responsible for confirming ``job.status`` is terminal (and, for COMPLETED, that ``result_ref`` is non-blank and unexpired) before @@ -60,4 +64,31 @@ def result_payload(job) -> dict: "status": "failed", "error": job.error or "Job failed", } + # A cancel that lost the race to a finished extraction keeps its result + # (`AgentKVJob.attach_result_to_cancelled`). The engine had already spent + # and flushed the OCR and LLM usage by the time the cancel could collide + # with the executor's final stage report, so the customer is billed either + # way -- and destroying the answer on top of that was the strictly worse + # half of the trade. + # + # `status` stays `cancelled`: the cancel WAS honoured and a client has to be + # able to see that. `success` is True because there is a real result and it + # was paid for. A client branching on `success` gets the data; one branching + # on `status` sees the cancellation. Neither is misled. + # + # `result_ref` is blank for the ordinary cancel -- nothing ran, or the run + # was stopped mid-flight -- which keeps that path byte-identical. A purged + # job never reaches here: `JobResultView` 404s on `files_purged` before + # calling this, which also covers the ref that survived a failed delete. + if job.result_ref: + usage = job.usage_summary or {} + return { + "success": True, + "status": "cancelled", + "extractors": {job.extractor: read_result(job.result_ref)}, + "usage_summary": { + "total": usage, + "by_extractor": {job.extractor: usage}, + }, + } return {"success": False, "status": "cancelled"} diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index de4583f5ff..5724d11537 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -51,6 +51,43 @@ def _sanitize_counters(counters) -> dict: } +def _attach_or_discard(job_id, org_id: str, result_ref: str, usage_summary) -> bool: + """Keep a just-written result on a CANCELLED job, or delete it. + + Shared by the two ways a finalize can arrive for a job a cancel has already + terminalized, which are NOT the same race and need to be told apart: + + * the cancel landed between this finalize's read and its ``mark_terminal`` + (``FinalizeView._complete`` lost the guard), and + * the cancel landed earlier, so the row already read as CANCELLED and the + finalize never attempted the guard at all + (``FinalizeView._salvage``) -- which is the ORDINARY shape of this race, + since the executor only learns of a cancel from its final stage report + and then reports success with the full output anyway. + + Either way the engine has already flushed the run's OCR and LLM usage, so + the customer has paid; the only question is whether the answer survives. + + Returns whether the result was kept. A refusal is the normal outcome for a + duplicate callback (the row already carries a ``result_ref``) or a purged + job, and the file written for THIS attempt is deleted so nothing is + orphaned. + """ + if AgentKVJob.attach_result_to_cancelled( + job_id, org_id, result_ref=result_ref, usage_summary=usage_summary + ): + logger.info( + "agent-kv job %s (org=%s) was cancelled after its extraction had " + "finished; its usage was already flushed, so the paid-for result is " + "KEPT and served alongside status=cancelled", + job_id, + org_id, + ) + return True + delete_result_file(result_ref) + return False + + def _stage_merge_expression(stage: str, entry: dict) -> CombinedExpression: """Build a DB-side jsonb merge expression for one stage's entry. @@ -166,9 +203,11 @@ class FinalizeView(APIView): """Terminalize a job: write its result (on success) then mark it done. Idempotent: a job already in a terminal state short-circuits before any - write -- ``write_result`` is only ever called for a job that is still - non-terminal at read time, so a duplicate/late finalize call can never - rewrite an already-written result. The concurrency slot is always + write, with ONE exception -- a success callback for an already-``CANCELLED`` + job, which writes its result and attaches it (``_salvage``). A duplicate or + late finalize still cannot rewrite an already-written result: the attach's + guard requires a blank ``result_ref``, and a refused attach deletes the file + that attempt wrote. The concurrency slot is always released, in a ``finally``, on every path that actually attempts to finalize (success, duplicate no-op, or an exception raised while finalizing) -- but never on a 400 for a malformed body, since nothing @@ -182,6 +221,16 @@ class FinalizeView(APIView): ``storage.delete_result_file`` rather than left to leak past even TTL cleanup (which only ever acts on a job's *stored* ``result_ref``). + With ONE exception, and it is the reason this class changed: when the guard + was lost to a CANCEL and the extraction had already finished, the result is + attached to the cancelled row instead of deleted + (``AgentKVJob.attach_result_to_cancelled``). The engine flushes its OCR and + LLM usage before the executor's final stage report, so by then the customer + has been billed -- deleting the file charged them for an answer and then + destroyed it. The status stays ``cancelled``; only a pointer to paid-for + work is added. Every other guard loss still deletes its own orphan, and the + attach's own return value is what distinguishes them. + Also deletes the staged input the moment ``mark_terminal`` actually wins (spec D10: "uploaded document deleted on job completion") -- the run is over either way, whether it completed or failed, so the input is @@ -195,8 +244,12 @@ class FinalizeView(APIView): permission_classes: list = [] @staticmethod - def _complete(job, job_id, org_id: str, body) -> bool: - """Persist the result and terminalize as COMPLETED. True if the guard won. + def _complete(job, job_id, org_id: str, body) -> tuple[bool, bool]: + """Persist the result and terminalize as COMPLETED. + + Returns ``(finalized, result_attached)``: whether the terminal-state + guard was won, and -- when it was NOT -- whether the result was + nevertheless kept on a cancelled row. ``write_result`` unavoidably runs *before* the ``mark_terminal`` guard (the result has to exist to get a ``result_ref`` to pass in), so a @@ -205,6 +258,25 @@ def _complete(job, job_id, org_id: str, body) -> bool: pointing at it. That orphan is deleted here rather than left to leak past even TTL cleanup, which only ever acts on a job's *stored* ``result_ref``. Best-effort; never blocks the response. + + **A cancel that lost the race does not forfeit the result.** The window + is real: the executor can finish extraction -- spending OCR and LLM, + both already flushed by the engine before its final stage report -- and + only then try that report, which is answered as a no-op because a cancel + terminalized the row in the meantime. The executor returns success with + the full output, this method writes it, and the guard below correctly + declines to change a terminal row's status. Deleting the file at that + point billed the customer for an answer and then destroyed it, with + ``GET result`` reporting only ``{"success": false, "status": + "cancelled"}``. + + So a CANCELLED row keeps it instead: ``attach_result_to_cancelled`` + attaches ``result_ref`` and ``usage_summary`` WITHOUT touching + ``status`` or ``completed_at``, so the cancel stays honoured and + ``mark_terminal`` remains the only way to reach a terminal state. The + orphan delete still runs for every other guard loss -- a duplicate + finalize against a COMPLETED row, say -- which is why the attach's + return value decides, rather than the fact that the guard was lost. """ result_ref = write_result(org_id, str(job_id), body.get("result") or {}) finalized = AgentKVJob.mark_terminal( @@ -216,19 +288,65 @@ def _complete(job, job_id, org_id: str, body) -> bool: ) if finalized: job.status = JobStatus.COMPLETED - else: - delete_result_file(result_ref) - return finalized + return True, False + return False, _attach_or_discard( + job_id, org_id, result_ref, body.get("usage_summary") + ) @staticmethod - def _fail(job, job_id, org_id: str, body) -> bool: - """Terminalize as FAILED. True if the guard won.""" + def _fail(job, job_id, org_id: str, body) -> tuple[bool, bool]: + """Terminalize as FAILED. ``(guard won, False)``. + + The second element is always False: there is no result to attach to + anything on a failure. It exists so both handlers share one shape and + ``post`` can call whichever it picked without knowing which it got. + """ finalized = AgentKVJob.mark_terminal( job_id, org_id, JobStatus.FAILED, error=body.get("error") or "" ) if finalized: job.status = JobStatus.FAILED - return finalized + return finalized, False + + @staticmethod + def _salvage(job_id, org_id: str, body) -> bool: + """Store a finished extraction's result on an ALREADY-cancelled job. + + **This is the ordinary shape of the cancelled-after-completion race, and + the one `_complete` cannot reach.** `post` only calls a handler when the + row it read was still non-terminal, so `_complete`'s own salvage covers + a window of microseconds: the cancel landing between that read and + `mark_terminal`. The real race is wider and earlier. The executor learns + of a cancel from its final stage report, which the backend answers as a + no-op once the row is terminal -- so by the time the callback worker + calls finalize, the row has read CANCELLED for however long that round + trip took, and `post` short-circuited before writing anything at all. + The fully computed, already-billed output was simply dropped on the + floor, and the one log line said "cancelled". + + So this writes it and attaches it. Deliberately narrow: + + * **success only.** A failed run has no result to keep. + * **CANCELLED only.** A COMPLETED row already has its own result and a + FAILED one legitimately has none; grafting a late callback's output + onto either is the overwrite the terminal guard exists to prevent. + `post` enforces the status, and + `AgentKVJob.attach_result_to_cancelled` enforces it again in SQL. + * **`status` and `completed_at` are never touched**, so the cancel stays + won and `mark_terminal` remains the only route to a terminal state. + This reports False as `finalized` for exactly that reason. + * **The staged input is NOT dropped.** A cancelled job's input rides the + TTL sweep (docs section 9), and `_drop_staged_input` stays gated on a + won guard. + + Note that this writes a result for a job that was ALREADY terminal at + read time, which the rest of this class is careful never to do. The + idempotency that rule protected is preserved by the attach instead: its + guard requires a blank `result_ref`, so a duplicate callback attaches + nothing and `_attach_or_discard` deletes the file this attempt wrote. + """ + result_ref = write_result(org_id, str(job_id), body.get("result") or {}) + return _attach_or_discard(job_id, org_id, result_ref, body.get("usage_summary")) @staticmethod def _drop_staged_input(job, job_id, org_id: str) -> None: @@ -265,12 +383,22 @@ def post(self, request, *args, job_id=None, **kwargs): job = AgentKVJob.objects.filter(id=job_id, organization_id=org_id).first() finalized = False + result_attached = False try: if job is not None and job.status not in AgentKVJob.TERMINAL: handler = self._complete if success else self._fail - finalized = handler(job, job_id, org_id, body) + finalized, result_attached = handler(job, job_id, org_id, body) if finalized: self._drop_staged_input(job, job_id, org_id) + elif job is not None and success and job.status == JobStatus.CANCELLED: + # The job was already CANCELLED when this request read it, and + # the executor still reported success with a full output -- + # which is what the cancelled-after-completion race actually + # looks like, since a cancel is only noticed at the final stage + # report. Keeping the result is the whole point of `_salvage`; + # see it for why this is the only terminal status that gets a + # second look, and why it still reports `finalized: False`. + result_attached = self._salvage(job_id, org_id, body) finally: AgentKVConcurrencyLimiter.release(org_id, str(job_id)) @@ -292,6 +420,13 @@ def post(self, request, *args, job_id=None, **kwargs): # Lets the callback worker tell "already terminal" (ordinary) # from "no such job" (a wiring fault) -- they were byte-identical. "reason": "ok" if job else "unknown_job", + # True only on the one path where `finalized` is False and the + # caller still needs telling: the job was CANCELLED after its + # extraction had finished, and the result was kept rather than + # discarded. The cancellation webhook has already gone out by + # then, so the callback worker sends a follow-up (see + # `_maybe_webhook`). Additive: every other path reports False. + "result_attached": result_attached, } ) diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index 0a537a9708..fbe087a010 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -254,6 +254,13 @@ def run_ttl_cleanup() -> dict: Platform-wide by design, same as :func:`run_sweep`. + **Every row this touches is stamped ``files_purged``**, before its deletes + are attempted and whether or not they succeed. The sweep is a purge, so it + is one of the two paths that owe that marker -- the other being + ``JobStatusView.delete`` -- and + ``AgentKVJob.attach_result_to_cancelled`` refuses to graft a late result + onto a row carrying it. See ``AgentKVJob.mark_files_purged``. + **Two queries, not one ordering.** Retaining a ref whose delete failed is what makes a retry possible at all, but it also means failed rows compete with new expirations for a capped batch, and either side can starve the @@ -319,7 +326,23 @@ def run_ttl_cleanup() -> dict: # unconditionally, so a transient object-store failure orphaned the file # permanently (the candidate filters above only match rows that still # carry a non-blank ref, so a blanked row can never be reconsidered). - cleared = delete_job_files(job) + # + # `mark_files_purged` first, for the same two reasons `JobStatusView. + # delete` calls it: it records the purge UNCONDITIONALLY (a failed + # delete below must not leave the marker False -- it says the files are + # being destroyed, not that they are gone), and it returns the row read + # under a lock so the deletes act on the refs that exist NOW. + # + # The lock is what stops `AgentKVJob.attach_result_to_cancelled` + # interleaving between this read and the blanking update below. Without + # it, an attach landing in that window sets a `result_ref` this pass has + # already read as blank, the blanking write then clears it without the + # file ever having been deleted, and the candidate filters above + # (`input_ref > "" OR result_ref > ""`) can never select the row again: + # extracted customer data left in the bucket past its retention window, + # with nothing pointing at it. See that method's docstring. + row = AgentKVJob.mark_files_purged(job.id, job.organization_id) or job + cleared = delete_job_files(row) fields: dict = dict.fromkeys(cleared, "") if len(cleared) == 2: cleaned += 1 diff --git a/backend/agent_kv/migrations/0008_agentkvjob_files_purged.py b/backend/agent_kv/migrations/0008_agentkvjob_files_purged.py new file mode 100644 index 0000000000..ea9d5bde0c --- /dev/null +++ b/backend/agent_kv/migrations/0008_agentkvjob_files_purged.py @@ -0,0 +1,44 @@ +"""Record purge intent explicitly instead of inferring it from the ref columns. + +UN-4232. A cancel that lands after an extraction has already finished -- and +after the engine has already flushed its OCR and LLM usage -- used to have its +result written and then deleted: the customer was billed for an answer that was +destroyed. Keeping the result requires knowing whether the caller has asked for +this job's data to be destroyed, and that question cannot be answered from +``input_ref``/``result_ref``: both columns mean "never set", "purged", and +"purge attempted but the object delete failed, so the ref is kept for a TTL +retry" at different times. Three attempts to infer it from them were reverted. + +``files_purged`` records the fact directly. Written unconditionally by +``DELETE /agent-kv/{job_id}`` and by ``run_ttl_cleanup`` before either tries a +delete, so a failed delete cannot leave it False; read by +``AgentKVJob.attach_result_to_cancelled`` (refuses to attach a late result to a +purged job) and by ``JobResultView`` (refuses to serve one, including the case +where a ref survived a failed delete, which no ref-based check can see). + +``default=False`` with no NULL state, deliberately: "we do not know whether +this was purged" is the condition this column exists to abolish. Existing rows +are correctly False -- a row written before this migration predates any purge +marker, and the API's behaviour for it (serve the result if a ref is there) is +unchanged. + +A plain AddField with a non-volatile default is metadata-only on PG 11+ (no +table rewrite, no backfill scan), so this needs no concurrent/batched +treatment -- and ``agent_kv_job`` has not been deployed anywhere yet regardless. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("agent_kv", "0007_remove_agent_kv_periodic_tasks"), + ] + + operations = [ + migrations.AddField( + model_name="agentkvjob", + name="files_purged", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index 45df84d8dd..d0ec1fe2c9 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -1,7 +1,7 @@ import uuid from account_v2.models import User -from django.db import models +from django.db import models, transaction from django.utils import timezone from utils.models.base_model import BaseModel from utils.models.organization_mixin import DefaultOrganizationMixin @@ -139,6 +139,39 @@ class AgentKVJob(DefaultOrganizationMixin, BaseModel): # which get the rest. Capping both is what stops either side starving the # other. See run_ttl_cleanup. cleanup_failed_at = models.DateTimeField(null=True, blank=True) + # Set once this job's stored files have been destroyed on purpose: by an + # explicit `DELETE /agent-kv/{job_id}` or by the TTL sweep. One-way, and + # written UNCONDITIONALLY by both of those paths -- before the deletes are + # attempted, and regardless of whether any individual delete succeeded. + # + # It exists because purge state CANNOT be inferred from `input_ref` / + # `result_ref`, and three reverted attempts to do so are the evidence. Those + # two columns carry three different meanings at once: + # + # * blank -- nothing was ever staged/produced, or + # * blank -- it was staged/produced and then purged, or + # * SET -- it was purged but the object delete FAILED, so the ref is + # deliberately kept as the handle `run_ttl_cleanup` retries + # from (see `storage.delete_job_files`). + # + # So neither "the ref is blank" nor "the ref is set" tells you whether the + # caller asked for this job's data to be destroyed. This column does, and + # it is the only thing that does. + # + # Two readers depend on that: + # + # * `attach_result_to_cancelled` refuses to attach a late result to a + # purged job -- otherwise data the caller DELETEd becomes readable again. + # * `JobResultView` refuses to SERVE a purged job's result even when a ref + # survived a failed delete, which is the case a ref-based check cannot + # see at all. + # + # Deliberately NOT "the files are gone": a failed delete leaves this True + # with the object still in the bucket and its ref still set, which is + # exactly what lets TTL cleanup retry while the API already treats the job + # as purged. "The caller asked for this to be destroyed" is a fact about the + # request, so it is recorded as one instead of being re-derived later. + files_purged = models.BooleanField(default=False) tags = models.JSONField(default=list, blank=True) custom_data = models.JSONField(null=True, blank=True) webhook_url = models.URLField(max_length=1024, blank=True, default="") @@ -163,6 +196,142 @@ class Meta: models.Index(fields=["cleanup_failed_at", "expires_at"]), ] + @classmethod + def mark_files_purged(cls, job_id, organization_id): + """Record that this job's files are being destroyed; return the row as + it is NOW. + + Two jobs in one call, and they have to be one call: + + 1. Set ``files_purged`` -- UNCONDITIONALLY, before any delete is + attempted. It records the caller's intent, not the outcome, so a + delete that fails must not leave it False (see the field's comment). + 2. Hand back the row read under ``SELECT ... FOR UPDATE``, so the caller + deletes the files that actually exist rather than the ones its own + earlier read saw. + + **Why the lock.** ``attach_result_to_cancelled`` can write a + ``result_ref`` onto a CANCELLED row at any moment. Without serializing + the two, this interleaving loses: + + purge reads the row -> result_ref "" + attach commits -> result_ref set, file written + purge sets files_purged + purge deletes files from its STALE copy -> only the input + purge blanks the refs it cleared + + which leaves a result file in the bucket that the purge never deleted, + on a row whose ref it then blanked -- so TTL cleanup (whose candidate + filter is ``input_ref > "" OR result_ref > ""``) can never reconsider + it, and extracted customer data outlives its retention window with + nothing pointing at it. Taking the row lock here, and taking it in + ``attach_result_to_cancelled`` too, makes the two mutually exclusive: + either the attach committed BEFORE this read (so its ref is in the row + returned, and its file is deleted with everything else) or it runs + after this commit (so it sees ``files_purged=True``, matches nothing, + and its caller deletes the file it just wrote). There is no third + interleaving. + + The lock is held only for the marker write -- the object-store deletes + happen after it is released, on the row returned here. Blocking a + concurrent attach for one UPDATE is the point; blocking it for the + duration of a bucket round-trip is not. + + Returns the row, or ``None`` if it no longer exists (job rows are + retained for the audit trail, so a caller that just read one can treat + ``None`` as unreachable and fall back to its own copy). + """ + with transaction.atomic(): + row = ( + cls.objects.select_for_update() + .filter(id=job_id, organization_id=organization_id) + .first() + ) + if row is None: + return None + cls.objects.filter(id=job_id, organization_id=organization_id).update( + files_purged=True + ) + row.files_purged = True + return row + + @classmethod + def attach_result_to_cancelled( + cls, job_id, organization_id, *, result_ref, usage_summary=None + ) -> bool: + """Keep a paid-for result on a job that was CANCELLED mid-flight. + + The race this exists for: extraction completes -- OCR and LLM already + spent and flushed by the engine -- and only then does the executor's + final stage report land, by which time a cancel has terminalized the + row. The executor returns success with the full output, and before this + it went nowhere: ``FinalizeView.post`` short-circuited on the + already-terminal row and never wrote it (the ordinary case, + ``FinalizeView._salvage``), or wrote it and then DELETED it as an orphan + when ``mark_terminal`` correctly declined to restatus a terminal row + (the narrow case, ``FinalizeView._complete``). Either way the customer + was billed for an answer that was destroyed, with ``GET result`` + reporting only ``{"success": false, "status": "cancelled"}`` and one + INFO line. + + Deliberately NOT part of ``mark_terminal``, and deliberately unable to + change ``status`` or ``completed_at``: the cancel stays won and stays + visible. This adds a pointer to work that was already paid for, nothing + more -- ``mark_terminal`` remains the only way to reach a terminal + state. ``result_payload`` then serves the data alongside + ``status: "cancelled"``. + + The guard is one filtered UPDATE, and every clause in it earns its place: + + * ``status=CANCELLED`` -- a COMPLETED or FAILED row already owns its + outcome, and grafting a late callback's result onto it would either + overwrite a real result or invent one for a run that failed. + * ``result_ref=""`` -- idempotency under at-least-once delivery. A + second callback matches nothing, returns False, and its caller deletes + the file IT just wrote instead of orphaning it. + * ``files_purged=False`` -- the purge guard. A job the caller DELETEd + (or one the TTL sweep sent through) must never get a result back; + doing so makes purged customer data readable again. **This cannot be + expressed with the ref columns** -- that was attempt 1 (``result_ref`` + alone: DELETE blanks the refs and leaves exactly the row the filter + matched) and attempt 2 (plus ``input_ref > ""``: unsound, because + ``delete_job_files`` deliberately KEEPS a ref whose delete failed, so + a non-blank ``input_ref`` does not prove the job was never purged). + See the ``files_purged`` field comment. + + ``select_for_update`` around it is what stops ``mark_files_purged`` + interleaving between this guard and its write -- the full argument is in + that method's docstring. Both sides must take the lock for either to be + safe. + + Returns True when this call attached the result (and the caller must + therefore keep the file), False when it did not (caller deletes it). + """ + if not result_ref: + return False + fields = {"result_ref": result_ref} + if usage_summary is not None: + fields["usage_summary"] = usage_summary + with transaction.atomic(): + # Locked by identity only; the guard itself lives in the UPDATE + # below so there is exactly one copy of it, in SQL. + locked = ( + cls.objects.select_for_update() + .filter(id=job_id, organization_id=organization_id) + .first() + ) + if locked is None: + return False + return bool( + cls.objects.filter( + id=job_id, + organization_id=organization_id, + status=JobStatus.CANCELLED, + result_ref="", + files_purged=False, + ).update(**fields) + ) + @classmethod def mark_terminal( cls, diff --git a/backend/agent_kv/tests/test_cancelled_result_salvage.py b/backend/agent_kv/tests/test_cancelled_result_salvage.py new file mode 100644 index 0000000000..d2cac9fd43 --- /dev/null +++ b/backend/agent_kv/tests/test_cancelled_result_salvage.py @@ -0,0 +1,629 @@ +"""A cancel that lost the race keeps its paid-for result -- and a purge wins. + +The defect: the engine flushes OCR and LLM usage BEFORE the executor's final +stage report, so a cancel colliding with that report lands after the customer +has been billed. ``FinalizeView._complete`` then wrote the result, +``mark_terminal`` correctly declined to restatus a terminal row, and the `else` +branch deleted the file -- money taken, answer destroyed, ``GET result`` +reporting only ``{"success": false, "status": "cancelled"}``. + +The obstacle, which is why this took four attempts: ``JobStatusView.delete`` +guaranteed that a purged job could not be resurrected BY RELYING on the late +finalize deleting its own orphaned write. Keeping the result removes exactly +that cleanup, so the guarantee has to be re-established some other way -- and +three attempts to re-establish it from ``input_ref``/``result_ref`` were +reverted, because those columns mean "never set", "purged" and "purge attempted, +delete failed, ref kept for a TTL retry" at different times. + +``files_purged`` records the fact instead of inferring it. These tests are +DB-backed on purpose: every mechanism here is a guarded UPDATE racing another +guarded UPDATE, or an ordering between a marker write and a read, and a mock +would only ever agree with whichever one happened to be written. That is how the +previous attempts passed review-less: 34 DB-free tests agreed with a condition +that skipped the re-read on the one path needing it, because their mocked +``mark_terminal`` left the in-memory row unmutated exactly as the real one does. + +DB-backed, so ``TestCase`` and the integration tier (``backend/conftest.py`` +auto-marks on that basis; no manual marker). +""" + +import os +from datetime import timedelta +from unittest import mock + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from account_v2.models import Organization # noqa: E402 +from django.test import TestCase # noqa: E402 +from django.utils import timezone # noqa: E402 +from utils.user_context import UserContext # noqa: E402 + +from agent_kv.models import AgentKVJob, JobExtractor, JobStatus # noqa: E402 + +_INPUT = "unstract/agent_kv/org/job/input.pdf" +_RESULT = "unstract/agent_kv/org/job/result-abc.json" + + +class _Rows(TestCase): + """Shared real-row scaffolding.""" + + def setUp(self): + self.org = Organization.objects.create( + display_name="Acme", organization_id="org_acme", name="acme" + ) + UserContext.set_organization_identifier(None) + + def _job(self, **kwargs): + # A cancelled job keeps its staged input until TTL, so a live one is the + # realistic default. Note that NOTHING here reads `input_ref` to decide + # whether a result may be attached -- that was attempt 2, and it was + # unsound; these tests pin that it stays gone. + kwargs.setdefault("input_ref", _INPUT) + job = AgentKVJob( + organization_id=self.org.id, extractor=JobExtractor.TABLE, **kwargs + ) + job.save() + return job + + +class TestTheSalvageAttachesAPaidForResult(_Rows): + def test_a_cancelled_job_gets_its_result(self): + job = self._job(status=JobStatus.CANCELLED) + + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref=_RESULT, usage_summary={"pages": 3} + ) + job.refresh_from_db() + + assert attached is True + assert job.result_ref == _RESULT + assert job.usage_summary == {"pages": 3} + + def test_it_never_changes_status_or_completed_at(self): + """The cancel stays won, and stays visible. + + ``mark_terminal`` is the only way to reach a terminal state. If this + ever starts flipping `status`, a cancelled job reads as completed and + that guard has been bypassed through the back door. + """ + stamped = timezone.now() + job = self._job(status=JobStatus.CANCELLED, completed_at=stamped) + + AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref=_RESULT) + job.refresh_from_db() + + assert job.status == JobStatus.CANCELLED + assert job.completed_at == stamped + + def test_a_completed_or_failed_job_is_left_alone(self): + """Scoped to CANCELLED on purpose: a COMPLETED or FAILED row already + owns its outcome, and grafting a late callback's result onto it would + overwrite a real one or invent one for a run that failed. + """ + for status in (JobStatus.COMPLETED, JobStatus.FAILED): + job = self._job(status=status) + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref=_RESULT + ) + job.refresh_from_db() + assert attached is False, status + assert job.result_ref == "", status + + def test_a_second_callback_does_not_overwrite_the_first(self): + """At-least-once delivery means this runs twice. The second must report + False so its caller deletes the file IT just wrote rather than orphaning + it. + """ + job = self._job(status=JobStatus.CANCELLED) + + first = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref=_RESULT + ) + second = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref="unstract/agent_kv/org/job/result-def.json" + ) + job.refresh_from_db() + + assert (first, second) == (True, False) + assert job.result_ref == _RESULT + + def test_a_blank_ref_is_refused_so_the_caller_keeps_its_delete(self): + job = self._job(status=JobStatus.CANCELLED) + + assert ( + AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref="") + is False + ) + + def test_another_org_cannot_attach_a_result(self): + other = Organization.objects.create( + display_name="Other", organization_id="org_other", name="other" + ) + job = self._job(status=JobStatus.CANCELLED) + + assert ( + AgentKVJob.attach_result_to_cancelled(job.id, other.id, result_ref=_RESULT) + is False + ) + job.refresh_from_db() + assert job.result_ref == "" + + +class TestThePurgeGuard(_Rows): + """A result the caller DELETEd must never come back. + + This is the invariant that killed the three previous attempts, so each test + here names the attempt it corresponds to. + """ + + def test_a_purged_job_does_not_get_a_result_grafted_on(self): + """Attempt 1's hole: gating on `result_ref=""` alone. DELETE blanks both + refs, which left exactly the row that filter matched. + """ + job = self._job( + status=JobStatus.CANCELLED, + input_ref="", + result_ref="", + files_purged=True, + ) + + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref=_RESULT + ) + job.refresh_from_db() + + assert attached is False, ( + "attached a result to a job the caller DELETEd; purged data must " + "not become readable again" + ) + assert job.result_ref == "" + + def test_a_purged_job_whose_file_delete_FAILED_is_also_refused(self): + """Attempt 2's hole, and the reason the marker exists. + + `delete_job_files` deliberately KEEPS a ref whose object delete failed, + so TTL cleanup has a handle to retry from. A purged job can therefore + have a non-blank `input_ref` -- which is why `input_ref > ""` was never + a sound "this job was not deleted" test, and why this row must still be + refused. + """ + job = self._job( + status=JobStatus.CANCELLED, + input_ref=_INPUT, # the delete failed; the ref is the retry handle + result_ref="", + files_purged=True, + ) + + assert ( + AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref=_RESULT) + is False + ) + + def test_a_cancelled_job_with_no_staged_input_still_gets_its_result(self): + """The other half of attempt 2 being wrong: a blank `input_ref` is not + evidence of a purge either, so it must not block the salvage. + """ + job = self._job(status=JobStatus.CANCELLED, input_ref="", files_purged=False) + + assert ( + AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref=_RESULT) + is True + ) + + +class TestTheMarkerItself(_Rows): + def test_the_claim_sets_the_marker_and_returns_the_fresh_row(self): + job = self._job(status=JobStatus.CANCELLED) + # Someone else's write, invisible to `job`. + AgentKVJob.objects.filter(id=job.id).update(result_ref=_RESULT) + + row = AgentKVJob.mark_files_purged(job.id, self.org.id) + + assert row is not None + assert row.files_purged is True + assert row.result_ref == _RESULT, ( + "the claim returned a stale row; DELETE would then delete files " + "from a snapshot that predates the write it is racing" + ) + job.refresh_from_db() + assert job.files_purged is True + + def test_the_claim_is_idempotent(self): + job = self._job(status=JobStatus.CANCELLED, files_purged=True) + + row = AgentKVJob.mark_files_purged(job.id, self.org.id) + + assert row.files_purged is True + + def test_another_org_cannot_claim_the_purge(self): + other = Organization.objects.create( + display_name="Other", organization_id="org_other", name="other" + ) + job = self._job(status=JobStatus.CANCELLED) + + assert AgentKVJob.mark_files_purged(job.id, other.id) is None + job.refresh_from_db() + assert job.files_purged is False + + def test_both_sides_take_the_row_lock(self): + """The lock is the mechanism; the sequential tests below only show the + two ORDERINGS it permits. + + What they cannot show is that a concurrent pair is forced into one of + those orderings rather than interleaving -- that needs two connections, + and a threaded test for it would be timing-dependent. So this asserts + the lock structurally instead: both statements must carry `FOR UPDATE`. + Lose it on either side and the interleaving documented in + `mark_files_purged` reopens -- a result file left in the bucket on a row + whose ref was blanked, unreachable by every later sweep. + """ + from django.db import connection + from django.test.utils import CaptureQueriesContext + + job = self._job(status=JobStatus.CANCELLED) + + with CaptureQueriesContext(connection) as claim: + AgentKVJob.mark_files_purged(job.id, self.org.id) + with CaptureQueriesContext(connection) as salvage: + AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref=_RESULT) + + assert any("FOR UPDATE" in q["sql"] for q in claim.captured_queries), ( + "the purge claim no longer locks the row; a salvage can interleave " + "between its read and the blanking write" + ) + assert any("FOR UPDATE" in q["sql"] for q in salvage.captured_queries), ( + "the salvage no longer locks the row; the purge claim can read it " + "between this guard and its write" + ) + + def test_the_claim_ordered_before_a_late_salvage_refuses_it(self): + """The ordering the whole fix rests on, played out in sequence. + + A real DELETE and a real late finalize interleave; this asserts the only + two outcomes that ordering permits. Here the purge lands first, so the + salvage must be refused outright -- its caller then deletes the file it + wrote, and nothing is orphaned and nothing purged is readable. + """ + job = self._job(status=JobStatus.CANCELLED) + + AgentKVJob.mark_files_purged(job.id, self.org.id) + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref=_RESULT + ) + + assert attached is False + job.refresh_from_db() + assert job.result_ref == "" + + def test_a_salvage_that_lands_first_is_visible_to_the_claim(self): + """The other permitted outcome: the salvage committed before the purge + read the row, so the purge SEES its ref and deletes that file too. This + is what replaces the old "the late finalize cleans up its own write". + """ + job = self._job(status=JobStatus.CANCELLED) + + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref=_RESULT + ) + row = AgentKVJob.mark_files_purged(job.id, self.org.id) + + assert attached is True + assert row.result_ref == _RESULT, ( + "the purge cannot see the result it has to delete; the file would " + "stay in the bucket on a row whose ref is then blanked" + ) + + +class TestDeleteEndToEnd(_Rows): + """`DELETE /agent-kv/{job_id}` against real rows.""" + + def _delete(self, job): + from agent_kv.execution_views import JobStatusView + + view = JobStatusView() + # The key validator and the object store are the only externals here; + # everything else runs for real. + with mock.patch("agent_kv.execution_views._get_job", return_value=job): + with mock.patch( + "agent_kv.execution_views.delete_job_files" + ) as m_delete_files: + m_delete_files.side_effect = self._delete_files + resp = JobStatusView.delete.__wrapped__( + view, mock.Mock(), job_id=job.id, agent_kv_key=mock.Mock() + ) + return resp + + @staticmethod + def _delete_files(row): + """Stand-in for the object store: both deletes succeed.""" + return ["input_ref", "result_ref"] + + def test_delete_marks_the_job_purged(self): + job = self._job(status=JobStatus.CANCELLED, result_ref=_RESULT) + + resp = self._delete(job) + + assert resp.status_code == 204 + job.refresh_from_db() + assert job.files_purged is True + assert (job.input_ref, job.result_ref) == ("", "") + + def test_delete_marks_the_job_purged_even_when_a_file_delete_FAILED(self): + """The marker records intent, not outcome. + + This is the case a ref-based check cannot see: the result ref survives + as TTL cleanup's retry handle, so "ref is blank" is not available as a + purge signal -- and without the marker this job went on SERVING that + result from the file that is still in the bucket. + """ + job = self._job(status=JobStatus.CANCELLED, result_ref=_RESULT) + + from agent_kv.execution_views import JobStatusView + + view = JobStatusView() + with mock.patch("agent_kv.execution_views._get_job", return_value=job): + with mock.patch( + "agent_kv.execution_views.delete_job_files", + # Input gone; the result delete raised inside delete_job_files. + return_value=["input_ref"], + ): + resp = JobStatusView.delete.__wrapped__( + view, mock.Mock(), job_id=job.id, agent_kv_key=mock.Mock() + ) + + assert resp.status_code == 204 + job.refresh_from_db() + assert job.files_purged is True + assert job.result_ref == _RESULT, "the retry handle must survive" + + def test_a_purged_job_refuses_a_later_salvage(self): + """The invariant, end to end: DELETE then a late finalize.""" + job = self._job(status=JobStatus.CANCELLED, input_ref=_INPUT) + + self._delete(job) + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref=_RESULT + ) + + assert attached is False + + +class TestTheTtlSweepClaimsThePurge(_Rows): + def _expired(self, **kwargs): + kwargs.setdefault("status", JobStatus.CANCELLED) + kwargs.setdefault("expires_at", timezone.now() - timedelta(days=1)) + return self._job(**kwargs) + + def test_ttl_cleanup_marks_every_row_it_processes(self): + from agent_kv import maintenance + + job = self._expired(result_ref=_RESULT) + + with mock.patch.object( + maintenance, "delete_job_files", return_value=["input_ref", "result_ref"] + ): + out = maintenance.run_ttl_cleanup() + + assert out["cleaned"] == 1 + job.refresh_from_db() + assert job.files_purged is True + + def test_ttl_cleanup_marks_the_row_even_when_a_delete_FAILED(self): + """Same reason as DELETE's: the ref is kept for a retry, so the marker + is the only record that this job's data was swept. + """ + from agent_kv import maintenance + + job = self._expired(result_ref=_RESULT) + + with mock.patch.object( + maintenance, "delete_job_files", return_value=["input_ref"] + ): + out = maintenance.run_ttl_cleanup() + + assert out["retained"] == 1 + job.refresh_from_db() + assert job.files_purged is True + assert job.result_ref == _RESULT + + def test_ttl_cleanup_sees_a_result_attached_before_its_read(self): + """No storage orphan: a salvage that committed before the sweep read the + row has its file deleted along with everything else. + + The ordering is what guarantees it -- the sweep stamps the marker and + re-reads under the same lock, so an attach is either visible here or + refused. Without that, the sweep blanked a ref whose file it never + deleted, and its own candidate filter (`input_ref > "" OR result_ref > + ""`) could never select the row again: extracted customer data left in + the bucket past its retention window. + """ + from agent_kv import maintenance + + job = self._expired() + AgentKVJob.attach_result_to_cancelled(job.id, self.org.id, result_ref=_RESULT) + + seen: list[str] = [] + + def _record(row): + seen.append(row.result_ref) + return ["input_ref", "result_ref"] + + with mock.patch.object(maintenance, "delete_job_files", side_effect=_record): + maintenance.run_ttl_cleanup() + + assert seen == [_RESULT] + job.refresh_from_db() + assert job.result_ref == "" + + +class TestTheFinalizeCallbackEndToEnd(_Rows): + """The whole race, driven through `FinalizeView` against real rows. + + This is the shape that matters and the one a unit test of + `attach_result_to_cancelled` cannot show: `FinalizeView.post` short-circuits + on an already-terminal row, so a salvage that lives only on the + guard-loss branch never runs for the real race -- the executor learns of a + cancel from its FINAL stage report, so the row has read CANCELLED for a + whole round trip by the time finalize is called. + """ + + def _finalize(self, job, body): + """Drive the real view; only the object store is stood in for. + + `delete_input` is patched to REPORT SUCCESS rather than left to the real + storage layer. Unpatched it raises, is swallowed, and returns False -- + so a salvage that wrongly dropped the staged input would leave + `input_ref` set anyway and + `test_the_cancelled_job_keeps_its_staged_input` would pass while the bug + was present. Mutation-checked: adding a `_drop_staged_input` call to the + salvage route must turn that test red. + """ + from rest_framework.test import APIRequestFactory + + from agent_kv import internal_views as iv + + request = APIRequestFactory().post("/x", body, format="json") + with mock.patch.object(iv.AgentKVConcurrencyLimiter, "release"): + with mock.patch.object(iv, "write_result", return_value=_RESULT): + with mock.patch.object(iv, "delete_input", return_value=True): + with mock.patch.object(iv, "delete_result_file") as m_del: + resp = iv.FinalizeView.as_view()(request, job_id=job.id) + return resp, m_del + + def _body(self, **extra): + body = { + "org_id": str(self.org.id), + "success": True, + "result": {"tables": [{"rows": 12}]}, + "usage_summary": {"pages": 3}, + } + body.update(extra) + return body + + def test_a_success_callback_for_a_cancelled_job_stores_its_result(self): + job = self._job(status=JobStatus.CANCELLED) + + resp, m_del = self._finalize(job, self._body()) + job.refresh_from_db() + + assert resp.status_code == 200 + assert resp.data["finalized"] is False + assert resp.data["result_attached"] is True + assert job.status == JobStatus.CANCELLED, "the cancel must stay won" + assert job.result_ref == _RESULT + assert job.usage_summary == {"pages": 3} + assert not m_del.called + + def test_the_cancelled_job_keeps_its_staged_input(self): + """`_drop_staged_input` stays gated on a won guard: a cancelled job's + input rides the TTL sweep (docs section 9), and the salvage must not + quietly change that. + """ + job = self._job(status=JobStatus.CANCELLED) + + self._finalize(job, self._body()) + job.refresh_from_db() + + assert job.input_ref == _INPUT + + def test_a_duplicate_callback_deletes_its_own_write(self): + job = self._job(status=JobStatus.CANCELLED) + + self._finalize(job, self._body()) + resp, m_del = self._finalize(job, self._body()) + job.refresh_from_db() + + assert resp.data["result_attached"] is False + m_del.assert_called_once_with(_RESULT) + assert job.result_ref == _RESULT, "the first write must stand" + + def test_a_callback_for_a_purged_job_deletes_its_own_write(self): + """The invariant three attempts broke: a purged job serves nothing, and + a late callback must not leave its file behind either. + """ + job = self._job(status=JobStatus.CANCELLED, files_purged=True) + + resp, m_del = self._finalize(job, self._body()) + job.refresh_from_db() + + assert resp.data["result_attached"] is False + m_del.assert_called_once_with(_RESULT) + assert job.result_ref == "" + + +class TestWhatTheResultEndpointServes(_Rows): + def _get_result(self, job): + from agent_kv.execution_views import JobResultView + + view = JobResultView() + with mock.patch("agent_kv.execution_views._get_job", return_value=job): + return JobResultView.get.__wrapped__( + view, mock.Mock(), job_id=job.id, agent_kv_key=mock.Mock() + ) + + def _fresh(self, **kwargs): + kwargs.setdefault("expires_at", timezone.now() + timedelta(days=1)) + return self._job(**kwargs) + + def test_a_salvaged_result_is_served_with_status_cancelled(self): + """`status` stays `cancelled` -- the cancel WAS honoured and a client + must be able to see it -- while `success` is True, because there is a + real result and it was paid for. + """ + job = self._fresh(status=JobStatus.CANCELLED, result_ref=_RESULT) + + with mock.patch( + "agent_kv.execution_views_result.read_result", + return_value={"tables": [], "row_count": 0}, + ): + resp = self._get_result(job) + + assert resp.status_code == 200 + assert resp.data["status"] == "cancelled" + assert resp.data["success"] is True + assert resp.data["extractors"]["table"] == {"tables": [], "row_count": 0} + + def test_an_ordinary_cancel_is_byte_identical_to_before(self): + """The common path -- cancelled mid-run, nothing computed -- must not + change shape just because the salvage exists. + """ + job = self._fresh(status=JobStatus.CANCELLED) + + resp = self._get_result(job) + + assert resp.data == {"success": False, "status": "cancelled"} + + def test_a_purged_cancelled_job_serves_nothing(self): + job = self._fresh( + status=JobStatus.CANCELLED, result_ref=_RESULT, files_purged=True + ) + + with mock.patch("agent_kv.execution_views_result.read_result") as m_read: + from agent_kv.exceptions import JobNotFound + + with self.assertRaises(JobNotFound): + self._get_result(job) + + assert not m_read.called + + def test_a_purged_COMPLETED_job_whose_delete_failed_serves_nothing(self): + """The pre-existing hole the marker also closes. + + `delete_job_files` keeps a ref whose object delete failed, so a DELETE + that could not remove the result file left `result_ref` set on a + COMPLETED row -- and this endpoint went on reading that very file. The + blank-ref check cannot see this case; only the marker can. + """ + job = self._fresh( + status=JobStatus.COMPLETED, result_ref=_RESULT, files_purged=True + ) + + from agent_kv.exceptions import JobNotFound + + with self.assertRaises(JobNotFound): + self._get_result(job) diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py index 20cba4c677..784770e462 100644 --- a/backend/agent_kv/tests/test_internal_views.py +++ b/backend/agent_kv/tests/test_internal_views.py @@ -366,6 +366,10 @@ def test_finalize_success_writes_result_then_marks_terminal( "webhook_url": "https://example.com/hook", "status": "completed", "reason": "ok", + # Additive, and False on every path but one: the result was + # kept on a row a cancel had already terminalized. See + # `test_finalize_guard_lost_to_a_cancel_keeps_the_paid_for_result`. + "result_attached": False, } assert [c[0] for c in manager.mock_calls] == ["write_result", "mark_terminal"] m_write.assert_called_once_with("org1", str(job_id), {"foo": "bar"}) @@ -412,13 +416,22 @@ def test_finalize_success_deletes_input_and_blanks_ref( # cancel raced in between the read and mark_terminal's guarded UPDATE) -- # the just-written result file has no ref anywhere pointing at it, so it's # cleaned up immediately rather than orphaned forever. +# `attach_result_to_cancelled` is pinned to False because that is what the real +# query returns for this row: its guard is `status=CANCELLED`, and this job is +# RUNNING. The patch is not cosmetic -- this suite is DB-free and the real +# method opens a transaction, so without it the request 500s and the orphan +# delete asserted below is never reached. Pinning it True instead would make +# this test assert the opposite behaviour, which is +# `test_finalize_guard_lost_to_a_cancel_keeps_the_paid_for_result` below. The +# salvage path itself is covered DB-backed in `test_models.py`. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) @mock.patch.object(iv, "write_result") @mock.patch.object(AgentKVJob, "objects") def test_finalize_success_guard_loss_deletes_orphaned_result( - m_jobs, m_write, m_mark_terminal, m_delete_result, m_release + m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release ): job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="") m_jobs.filter.return_value.first.return_value = job @@ -438,6 +451,185 @@ def test_finalize_success_guard_loss_deletes_orphaned_result( m_delete_result.assert_called_once_with("org/o/agent_kv/j/result.json") +# (7c-ii) the one case (7c) must NOT catch: the guard was lost to a CANCEL, and +# the run had already finished -- so the engine had already flushed its OCR and +# LLM usage and the customer had already been billed. Deleting the result there +# charged them for an answer and then destroyed it, with `GET result` reporting +# only `{"success": false, "status": "cancelled"}`. It is attached to the +# cancelled row instead, and the caller is owed a follow-up notification because +# the cancellation webhook has already gone out. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=True) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_guard_lost_to_a_cancel_keeps_the_paid_for_result( + m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release +): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + m_write.return_value = "org/o/agent_kv/j/result.json" + + job_id = uuid.uuid4() + resp = iv.FinalizeView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "success": True, + "result": {"tables": []}, + "usage_summary": {"pages": 3}, + }, + ), + job_id=job_id, + ) + + assert resp.status_code == 200 + # Still not "finalized": the cancel won the status and this must not pretend + # otherwise -- `mark_terminal` stays the only route to a terminal state. + assert resp.data["finalized"] is False + # The flag the callback worker needs: `finalized` False would otherwise mean + # "someone else already notified", and for this path nobody has. + assert resp.data["result_attached"] is True + m_salvage.assert_called_once() + kwargs = m_salvage.call_args.kwargs + assert kwargs["result_ref"] == "org/o/agent_kv/j/result.json" + assert kwargs["usage_summary"] == {"pages": 3} + assert not m_delete_result.called, ( + "deleted a result the customer had already been billed for; a cancel " + "that lost the race keeps its answer" + ) + + +# (7c-iii) the attach is what decides, not the guard loss. A salvage that is +# REFUSED -- a purged job, a COMPLETED row, a duplicate callback -- must still +# leave no orphan behind, which is the invariant (7c) protects and the reason +# `_complete` branches on the attach's return value rather than on `finalized`. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_refused_salvage_still_deletes_its_own_write( + m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release +): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + m_write.return_value = "org/o/agent_kv/j/result.json" + + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {"tables": []}}), + job_id=uuid.uuid4(), + ) + + assert resp.data["result_attached"] is False + m_delete_result.assert_called_once_with("org/o/agent_kv/j/result.json") + + +# (7c-iv) THE case the gap was actually about, and the one (7c-ii) cannot reach. +# +# `post` only calls a handler when the row it read was still non-terminal, so +# `_complete`'s salvage covers a window of microseconds. The real race is wider: +# the executor only learns of a cancel from its FINAL stage report, so by the +# time the callback worker calls finalize the row has read CANCELLED for a whole +# round trip. `post` short-circuited there and the fully computed, already-billed +# output was dropped without even being written -- which is why a salvage that +# lives only in `_complete` fixes almost nothing. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=True) +@mock.patch.object(AgentKVJob, "mark_terminal") +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_for_an_already_cancelled_job_keeps_its_result( + m_jobs, m_write, m_mark_terminal, m_salvage, m_delete_result, m_release +): + job = AgentKVJob( + status=JobStatus.CANCELLED, webhook_url="https://example.com/hook" + ) + m_jobs.filter.return_value.first.return_value = job + m_write.return_value = "org/o/agent_kv/j/result.json" + + job_id = uuid.uuid4() + resp = iv.FinalizeView.as_view()( + _post( + "/x", + { + "org_id": "org1", + "success": True, + "result": {"tables": [{"rows": 12}]}, + "usage_summary": {"pages": 3}, + }, + ), + job_id=job_id, + ) + + assert resp.status_code == 200 + m_write.assert_called_once_with("org1", str(job_id), {"tables": [{"rows": 12}]}) + m_salvage.assert_called_once() + assert m_salvage.call_args.kwargs["result_ref"] == "org/o/agent_kv/j/result.json" + assert m_salvage.call_args.kwargs["usage_summary"] == {"pages": 3} + assert not m_delete_result.called + # The cancel keeps the status, so this stays False -- and the attach is what + # tells the callback worker a notification is still owed. + assert resp.data["finalized"] is False + assert resp.data["result_attached"] is True + assert not m_mark_terminal.called, ( + "a salvage must not attempt the terminal guard; mark_terminal stays the " + "only route to a terminal state and this row already reached one" + ) + + +# (7c-v) ...and nothing else gets a second look. A success callback for an +# already-COMPLETED or already-FAILED row must still write NOTHING -- that is +# the idempotency the short-circuit protects, and widening the salvage past +# CANCELLED would overwrite a real result or invent one for a failed run. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled") +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_finalize_for_a_completed_or_failed_job_writes_nothing( + m_jobs, m_write, m_salvage, m_release +): + for status in (JobStatus.COMPLETED, JobStatus.FAILED): + job = AgentKVJob(status=status, webhook_url="") + m_jobs.filter.return_value.first.return_value = job + + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {"foo": "bar"}}), + job_id=uuid.uuid4(), + ) + + assert resp.data["finalized"] is False, status + assert resp.data["result_attached"] is False, status + assert not m_write.called, status + assert not m_salvage.called, status + + +# (7c-vi) a FAILURE callback for an already-cancelled job keeps the old +# short-circuit: there is no result to salvage, so nothing is written. +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled") +@mock.patch.object(iv, "write_result") +@mock.patch.object(AgentKVJob, "objects") +def test_failure_finalize_for_a_cancelled_job_salvages_nothing( + m_jobs, m_write, m_salvage, m_release +): + job = AgentKVJob(status=JobStatus.CANCELLED, webhook_url="") + m_jobs.filter.return_value.first.return_value = job + + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": False, "error": "boom"}), + job_id=uuid.uuid4(), + ) + + assert resp.data["result_attached"] is False + assert not m_write.called + assert not m_salvage.called + + # (7d) success finalize that WINS the guard never deletes the result it just # wrote -- only a guard-LOSS orphan triggers cleanup. @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") @@ -472,10 +664,15 @@ def test_finalize_success_guard_win_does_not_delete_result( # result.json path let the loser delete the winner's live result). @mock.patch.object(storage, "FileSystem") @mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +# False for the same reason as the guard-loss case above: the row the loser +# raced is COMPLETED, not CANCELLED, so the real guard matches nothing and the +# loser must still delete the file it just wrote. Also keeps this DB-free test +# out of the real method's transaction. +@mock.patch.object(AgentKVJob, "attach_result_to_cancelled", return_value=False) @mock.patch.object(AgentKVJob, "mark_terminal", side_effect=[True, False]) @mock.patch.object(AgentKVJob, "objects") def test_finalize_duplicate_success_race_loser_deletes_only_its_own_result( - m_jobs, m_mark, m_release, m_fs + m_jobs, m_mark, m_salvage, m_release, m_fs ): # Two separate reads that both observed the job as still RUNNING (the race # window before either guarded UPDATE ran). @@ -534,6 +731,10 @@ def test_finalize_duplicate_does_not_rewrite_result( "webhook_url": "https://example.com/hook", "status": "completed", "reason": "ok", + # Additive, and False on every path but one: the result was + # kept on a row a cancel had already terminalized. See + # `test_finalize_guard_lost_to_a_cancel_keeps_the_paid_for_result`. + "result_attached": False, } assert not m_write.called assert not m_mark_terminal.called @@ -591,6 +792,10 @@ def test_finalize_failure_records_error(m_jobs, m_write, m_mark_terminal, m_rele "webhook_url": "", "status": "failed", "reason": "ok", + # Additive, and False on every path but one: the result was + # kept on a row a cancel had already terminalized. See + # `test_finalize_guard_lost_to_a_cancel_keeps_the_paid_for_result`. + "result_attached": False, } assert not m_write.called m_mark_terminal.assert_called_once_with( diff --git a/backend/agent_kv/tests/test_job_views.py b/backend/agent_kv/tests/test_job_views.py index 5d61eb6356..0117683f6c 100644 --- a/backend/agent_kv/tests/test_job_views.py +++ b/backend/agent_kv/tests/test_job_views.py @@ -380,12 +380,23 @@ def test_cancel_on_completed_is_409_and_result_untouched( # --------------------------------------------------------------------------- # (8) delete calls delete_job_files and blanks both refs. # --------------------------------------------------------------------------- +# `mark_files_purged` is stubbed to None throughout this DB-free suite. DELETE +# calls it on EVERY request now -- it writes the `files_purged` marker and hands +# back the row read under a lock -- and the real method opens a transaction, so +# the request would 500 here. Returning None exercises the view's documented +# fallback (`... or job`), which keeps these cases asserting exactly what they +# were written to assert: the files the caller's own row points at. The claim's +# real return value is honoured in +# `test_delete_acts_on_the_row_the_purge_claim_returns`, that it happens at all +# in `test_delete_claims_the_purge_even_when_it_wins_the_cancel`, and the marker +# itself DB-backed in `test_models.py`. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files") @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") def test_delete_calls_delete_job_files_and_blanks_refs( - m_keys, m_jobs, m_delete_files, m_save + m_keys, m_jobs, m_delete_files, m_save, m_purge ): m_keys.get.return_value = kv_key() job = AgentKVJob( @@ -412,13 +423,15 @@ def test_delete_calls_delete_job_files_and_blanks_refs( # files are deleted -- a still-running job that finalizes late would # otherwise write a fresh result onto a job the caller just deleted. # --------------------------------------------------------------------------- +# See the note on `mark_files_purged` at (8); stubbed for the same reason. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files") @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") def test_delete_on_running_job_cancels_before_deleting_files( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_purge ): m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.RUNNING) @@ -447,6 +460,8 @@ def test_delete_on_running_job_cancels_before_deleting_files( # leaked a slot until the 6h TTL, and enough of them exhaust the org's # allowance and start rejecting new submits. # --------------------------------------------------------------------------- +# See the note on `mark_files_purged` at (8); stubbed for the same reason. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) @@ -454,7 +469,7 @@ def test_delete_on_running_job_cancels_before_deleting_files( @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") def test_delete_on_a_running_job_does_NOT_release_the_slot( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_purge ): """Inverted: this used to assert the release, which was the bug. @@ -483,6 +498,8 @@ def test_delete_on_a_running_job_does_NOT_release_the_slot( ) +# See the note on `mark_files_purged` at (8); stubbed for the same reason. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(ev, "dispatch_cancelled_webhook") @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @@ -491,7 +508,8 @@ def test_delete_on_a_running_job_does_NOT_release_the_slot( @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") def test_delete_on_a_never_dispatched_job_DOES_release_the_slot( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook, + m_purge, ): """The case the release exists for, and the only one. @@ -519,15 +537,16 @@ def test_delete_on_a_never_dispatched_job_DOES_release_the_slot( # --------------------------------------------------------------------------- @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") -# A lost race re-reads the row before cleanup (see 8i); this suite touches no -# database, so the read itself is stubbed out here. -@mock.patch.object(AgentKVJob, "refresh_from_db") +# DELETE re-reads the row before cleanup (see 8i) -- now through the purge claim +# rather than a bare `refresh_from_db`. This suite touches no database, so the +# claim is stubbed out here; see the note at (8). +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") def test_delete_does_not_release_the_slot_when_it_loses_the_terminal_race( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_refresh, m_save, m_release + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_purge, m_save, m_release ): m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.RUNNING) @@ -547,12 +566,14 @@ def test_delete_does_not_release_the_slot_when_it_loses_the_terminal_race( # still in the bucket orphans that object permanently. Still 204 -- the job is # terminal and the caller's intent is recorded. # --------------------------------------------------------------------------- +# See the note on `mark_files_purged` at (8); stubbed for the same reason. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files") @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") def test_delete_keeps_the_ref_whose_file_delete_failed( - m_keys, m_jobs, m_delete_files, m_save + m_keys, m_jobs, m_delete_files, m_save, m_purge ): m_keys.get.return_value = kv_key() job = AgentKVJob( @@ -576,13 +597,15 @@ def test_delete_keeps_the_ref_whose_file_delete_failed( # (8c) delete on an already-terminal job never attempts to cancel it again -- # unchanged behavior for the terminal case. # --------------------------------------------------------------------------- +# See the note on `mark_files_purged` at (8); stubbed for the same reason. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files") @mock.patch.object(AgentKVJob, "mark_terminal") @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") def test_delete_on_terminal_job_does_not_call_mark_terminal( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_purge ): m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.COMPLETED) @@ -625,18 +648,21 @@ def test_all_job_views_401_without_key(): # --------------------------------------------------------------------------- -# (8i) Losing the terminal race must RE-READ the job before touching its files. +# (8i) DELETE must act on the row the purge claim returns, not on its own +# earlier read. # # The race: DELETE reads a RUNNING job (result_ref ""), the finalize callback # wins the guarded UPDATE in between and writes COMPLETED + a real result_ref, -# and `mark_terminal` here returns False. Without a refresh the cleanup below -# runs against the stale copy: `delete_job_files` reports an already-empty -# `result_ref` as "cleared" (there was nothing to delete), and the save then -# writes "" OVER the winner's real ref. The job stays COMPLETED, its result -# 404s, and the object is orphaned -- TTL cleanup selects on `result_ref > ""`, -# so a blanked row never comes back. +# and `mark_terminal` here returns False. Acting on the stale copy, +# `delete_job_files` reports an already-empty `result_ref` as "cleared" (there +# was nothing to delete), and the save then writes "" OVER the winner's real +# ref. The job stays COMPLETED, its result 404s, and the object is orphaned -- +# TTL cleanup selects on `result_ref > ""`, so a blanked row never comes back. # -# Reported by Greptile on PR #2317. +# Originally reported by Greptile on PR #2317 against a bare `refresh_from_db` +# on the lost-race branch. `AgentKVJob.mark_files_purged` now owns that re-read +# (it has to: it must also be ordered against a concurrent salvage), so this +# pins the row it hands back actually being the one that gets cleaned up. # --------------------------------------------------------------------------- @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @@ -644,7 +670,7 @@ def test_all_job_views_401_without_key(): @mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_refreshes_the_job_when_it_loses_the_terminal_race( +def test_delete_acts_on_the_row_the_purge_claim_returns( m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release ): m_keys.get.return_value = kv_key() @@ -652,17 +678,18 @@ def test_delete_refreshes_the_job_when_it_loses_the_terminal_race( job.organization_id = "org1" m_jobs.get.return_value = job - # The winner's write, applied by refresh_from_db: COMPLETED + a real ref. - def _win_the_race(): - job.status = JobStatus.COMPLETED - job.result_ref = "org1/job/result.json" + # What the claim reads back under its lock: the winner's write. + fresh = AgentKVJob( + status=JobStatus.COMPLETED, result_ref="org1/job/result.json", files_purged=True + ) + fresh.organization_id = "org1" with mock.patch.object( - AgentKVJob, "refresh_from_db", side_effect=_win_the_race - ) as m_refresh: + AgentKVJob, "mark_files_purged", return_value=fresh + ) as m_purge: # Capture what the cleanup actually SAW. `call_args` would be useless - # here: it records a reference to the same job object the view then - # blanks, so by assertion time it reads "" whether the fix works or not. + # here: it records a reference to the object the view then blanks, so by + # assertion time it reads "" whether the fix works or not. seen: list[str] = [] def _record(j): @@ -673,38 +700,62 @@ def _record(j): resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) assert resp.status_code == 204 - assert m_refresh.called, ( - "a lost terminal race must re-read the job; acting on the stale copy " - "blanks the winner's result_ref and orphans the result file" + assert m_purge.called, ( + "DELETE skipped the purge claim; acting on its own stale copy blanks " + "the winner's result_ref and orphans the result file" ) # The file helper saw the winner's ref, so the real object is deleted... assert seen == ["org1/job/result.json"] # ...and the blanking is of a ref that was genuinely cleared, not a stale "". assert m_save.call_args.kwargs["update_fields"] == ["input_ref", "result_ref"] + assert fresh.result_ref == "" +# --------------------------------------------------------------------------- +# (8i-ii) The claim is made on EVERY delete, including one that wins the cancel. +# +# This replaces `test_delete_does_not_refresh_when_it_wins_the_terminal_race`, +# whose premise -- "winning means nothing else wrote to the row, so the extra +# query is waste" -- the salvage invalidates: a late finalize can still ATTACH a +# result to the row this request just cancelled, and the marker that stops it +# serving one has to be written here. +# +# Note what makes a conditional version of this quietly wrong. `mark_terminal` +# is a queryset UPDATE and never touches the in-memory row, so `job.status` is +# still RUNNING below even though this request won the cancel -- a re-read +# gated on `job.status == CANCELLED` would skip precisely the path that needs +# it, and this mock reproduces the un-mutated row faithfully, so it would agree +# with that mistake. The claim is unconditional, so there is no condition to get +# wrong. +# --------------------------------------------------------------------------- +@mock.patch.object(ev, "dispatch_cancelled_webhook") @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @mock.patch.object(ev, "delete_job_files", return_value=["input_ref", "result_ref"]) @mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") -def test_delete_does_not_refresh_when_it_wins_the_terminal_race( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release +def test_delete_claims_the_purge_even_when_it_wins_the_cancel( + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook ): - """Winning means nothing else wrote to the row, so the in-memory copy is - current and the extra query would be waste on the common path. - """ m_keys.get.return_value = kv_key() job = AgentKVJob(status=JobStatus.RUNNING) + job.id = uuid.uuid4() job.organization_id = "org1" m_jobs.get.return_value = job - with mock.patch.object(AgentKVJob, "refresh_from_db") as m_refresh: - resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=uuid.uuid4()) + with mock.patch.object( + AgentKVJob, "mark_files_purged", return_value=None + ) as m_purge: + resp = ev.JobDeleteView.as_view()(_authed(method="delete"), job_id=job.id) assert resp.status_code == 204 - assert not m_refresh.called + assert job.status == JobStatus.RUNNING, ( + "guard for the trap this test exists for: if mark_terminal ever starts " + "mutating the in-memory row, a status-gated re-read would start passing " + "here for the wrong reason" + ) + m_purge.assert_called_once_with(job.id, "org1") # --------------------------------------------------------------------------- @@ -763,6 +814,8 @@ def test_cancel_that_loses_does_not_queue_a_webhook( assert not m_hook.called +# See the note on `mark_files_purged` at (8); stubbed for the same reason. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(ev, "dispatch_cancelled_webhook") @mock.patch.object(ev.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "save") @@ -771,7 +824,8 @@ def test_cancel_that_loses_does_not_queue_a_webhook( @mock.patch.object(AgentKVJob, "objects") @mock.patch.object(AgentKVKey, "objects") def test_delete_that_cancels_a_running_job_also_queues_the_webhook( - m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook + m_keys, m_jobs, m_mark_terminal, m_delete_files, m_save, m_release, m_hook, + m_purge, ): """DELETE on a non-terminal job cancels it first; that cancellation is just as terminal, and just as invisible to the caller without this. diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py index 94001448ef..dace97106d 100644 --- a/backend/agent_kv/tests/test_sweeps.py +++ b/backend/agent_kv/tests/test_sweeps.py @@ -457,10 +457,19 @@ def test_ttl_cleanup_fresh_lane_gets_the_whole_batch_when_nothing_is_retrying( # leave their files in storage indefinitely. Both orderings tried before this # starved one side: oldest-expiry-first starved fresh work, NULLS FIRST starved # retries. +# `mark_files_purged` is stubbed to None throughout the DB-free TTL cases. +# Cleanup claims the purge on every row it processes -- the marker has to be +# written whether or not the deletes below succeed, and the row it reads back +# under the lock is what the deletes then act on -- and the real method opens +# a transaction, which this suite has no database for. None exercises the +# documented `... or job` fallback, so these cases keep asserting exactly what +# they were written to assert. The claim itself is covered DB-backed in +# `test_models.py::TestTheTtlSweepClaimsThePurge`. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(maintenance, "delete_job_files", return_value=["input_ref"]) @mock.patch.object(AgentKVJob, "objects") def test_ttl_cleanup_reserves_capacity_for_retries_under_a_flood_of_new_expiries( - m_objects, m_delete + m_objects, m_delete, m_purge ): retries = [ AgentKVJob(id=uuid.uuid4(), input_ref=f"r{i}", cleanup_failed_at=timezone.now()) @@ -481,9 +490,11 @@ def test_ttl_cleanup_reserves_capacity_for_retries_under_a_flood_of_new_expiries # (7) files are deleted BEFORE the refs are blanked -- order matters: a # delete failure must not blank a ref pointing at a file that's still there. +# Stubbed for the same reason as (6d); see the note there. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(maintenance, "delete_job_files") @mock.patch.object(AgentKVJob, "objects") -def test_ttl_cleanup_deletes_files_before_blanking_refs(m_objects, m_delete): +def test_ttl_cleanup_deletes_files_before_blanking_refs(m_objects, m_delete, m_purge): job_id = uuid.uuid4() job = AgentKVJob( id=job_id, input_ref="org/o/j/input.pdf", result_ref="org/o/j/result.json" @@ -507,9 +518,11 @@ def test_ttl_cleanup_deletes_files_before_blanking_refs(m_objects, m_delete): # (8) both refs are blanked via `.update()` (not `job.save()`), targeting # exactly this job's row, and the row itself is left in place (no .delete() # call is ever made on the queryset). +# Stubbed for the same reason as (6d); see the note there. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(maintenance, "delete_job_files") @mock.patch.object(AgentKVJob, "objects") -def test_ttl_cleanup_blanks_both_refs_for_the_job_row(m_objects, m_delete): +def test_ttl_cleanup_blanks_both_refs_for_the_job_row(m_objects, m_delete, m_purge): job_id = uuid.uuid4() job = AgentKVJob(id=job_id, input_ref="org/o/j/input.pdf", result_ref="") m_delete.return_value = ["input_ref", "result_ref"] @@ -527,9 +540,11 @@ def test_ttl_cleanup_blanks_both_refs_for_the_job_row(m_objects, m_delete): # (9) `cleaned` counts jobs actually processed this call, across multiple # candidates. +# Stubbed for the same reason as (6d); see the note there. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(maintenance, "delete_job_files") @mock.patch.object(AgentKVJob, "objects") -def test_ttl_cleanup_returns_count_of_jobs_cleaned(m_objects, m_delete): +def test_ttl_cleanup_returns_count_of_jobs_cleaned(m_objects, m_delete, m_purge): job1 = AgentKVJob(id=uuid.uuid4(), input_ref="a", result_ref="") job2 = AgentKVJob(id=uuid.uuid4(), input_ref="", result_ref="b") m_delete.return_value = ["input_ref", "result_ref"] @@ -575,9 +590,11 @@ def test_ttl_cleanup_with_no_candidates_is_a_pure_noop(m_objects, m_delete): # called with both refs blanked and the still-present result file loses its only # handle, since the candidate query below matches only rows with a non-blank # ref. +# Stubbed for the same reason as (6d); see the note there. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(maintenance, "delete_job_files") @mock.patch.object(AgentKVJob, "objects") -def test_ttl_cleanup_keeps_the_ref_whose_file_delete_failed(m_objects, m_delete): +def test_ttl_cleanup_keeps_the_ref_whose_file_delete_failed(m_objects, m_delete, m_purge): job_id = uuid.uuid4() job = AgentKVJob( id=job_id, input_ref="org/o/j/input.pdf", result_ref="org/o/j/result.json" @@ -607,9 +624,11 @@ def test_ttl_cleanup_keeps_the_ref_whose_file_delete_failed(m_objects, m_delete) # a NULL sorts FIRST under the candidate ordering -- so the row would hold the # head of every batch indefinitely, which is the starvation this ordering # exists to prevent. +# Stubbed for the same reason as (6d); see the note there. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(maintenance, "delete_job_files") @mock.patch.object(AgentKVJob, "objects") -def test_ttl_cleanup_blanks_no_ref_but_still_stamps_the_failure(m_objects, m_delete): +def test_ttl_cleanup_blanks_no_ref_but_still_stamps_the_failure(m_objects, m_delete, m_purge): job = AgentKVJob( id=uuid.uuid4(), input_ref="org/o/j/input.pdf", result_ref="org/o/j/r.json" ) @@ -631,9 +650,11 @@ def test_ttl_cleanup_blanks_no_ref_but_still_stamps_the_failure(m_objects, m_del # marker cleared. It drops out of the candidate filter anyway (both refs blank), # so this matters for the audit trail and for anything reading # cleanup_failed_at as "currently failing" rather than "failed once". +# Stubbed for the same reason as (6d); see the note there. +@mock.patch.object(AgentKVJob, "mark_files_purged", return_value=None) @mock.patch.object(maintenance, "delete_job_files") @mock.patch.object(AgentKVJob, "objects") -def test_ttl_cleanup_clears_a_stale_failure_marker_on_success(m_objects, m_delete): +def test_ttl_cleanup_clears_a_stale_failure_marker_on_success(m_objects, m_delete, m_purge): job = AgentKVJob( id=uuid.uuid4(), input_ref="org/o/j/input.pdf", diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 431910cfeb..efd9bec4c4 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -475,7 +475,11 @@ Re-readable until `expires_at` (D11), plus explicit `DELETE`. - **Non-terminal job** (`PENDING`/`DISPATCHED`/`RUNNING`): `409` with `{"status": ""}`. -- **Expired, or `COMPLETED` with an already-swept/blank `result_ref`**: `404`. +- **Expired, `COMPLETED` with an already-swept/blank `result_ref`, or purged by + `DELETE`/TTL cleanup**: `404`. The purge case is keyed off the job's + `files_purged` marker, not off a blank ref — `DELETE` deliberately keeps a ref + whose object delete failed so TTL cleanup can retry it, so a surviving ref is + not evidence that the data is still yours to read ([§7](#7-delete--delete-agent-kvjob_id)). - **`COMPLETED`**: `200` with the engine's full result object, unchanged (record, normalized_record, per-field audit trail, `qa_passed`, `challenge_passed`, `consistency_violations`, `cost_summary`, `timing` — spec §4; this repo does not @@ -483,7 +487,14 @@ Re-readable until `expires_at` (D11), plus explicit `DELETE`. finalize call sent). - **`FAILED`**: `200` with `{"success": false, "status": "failed", "error": ""}`. -- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}`. +- **`CANCELLED`**: `200` with `{"success": false, "status": "cancelled"}` — unless + the cancel lost a race to an extraction that had already finished, in which case + the result is served: `200` with `{"success": true, "status": "cancelled", + "extractors": {...}, "usage_summary": {...}}`. The status stays `cancelled` + because the cancel *was* honoured; `success` is `true` because there is a real + result and it was already billed. Branch on `success` for "is there data", + on `status` for "was this cancelled" — see + [§6](#6-cancel--post-agent-kvjob_idcancel). **Calculation result shape** (when `calculations` is supplied and `AGENT_KV_CALCULATIONS_ENABLED=true`): a `COMPLETED` result adds these fields to the @@ -636,37 +647,73 @@ the engine's own progress points (rate-limited, so detection can lag by a few se and the job then terminates as `cancelled`. Work already done is still billed and metered; work not yet started is not. -> **Known gap: a job that finishes before the cancel is noticed has its result -> discarded on arrival, and you are still billed for it.** The engine flushes OCR -> and LLM usage *before* the executor's final stage report, so a cancel colliding -> with that report lands after the work is paid for. `FinalizeView` then writes -> the result, its `mark_terminal` guard correctly declines to restatus a terminal -> row, and the file is deleted — `GET result` reports -> `{"success": false, "status": "cancelled"}` with no indication that a -> fully-computed answer existed. +**A job that finishes before the cancel is noticed keeps its result.** The +engine flushes OCR and LLM usage *before* the executor's final stage report, so +a cancel colliding with that report lands after the work is paid for. The +extraction is complete and the output fully computed; what used to happen to it +was nothing good, in either of two ways: + +- **The ordinary case.** The executor learns of the cancel *from* that final + stage report (answered as a no-op once the row is terminal) and returns + success with the full output anyway. By the time the callback worker calls + finalize, the row has read `CANCELLED` for a whole round trip, so + `FinalizeView.post` short-circuited on "already terminal" and the output was + dropped without even being written. +- **The narrow case.** The cancel lands between finalize's own read and its + `mark_terminal`, so the result *was* written and the guard then correctly + declined to restatus a terminal row — and the file was deleted as an orphan. + +Either way `GET result` reported `{"success": false, "status": "cancelled"}` and +the customer had paid for an answer that no longer existed. Both paths now +attach it to the cancelled row instead +(`FinalizeView._salvage` / `AgentKVJob.attach_result_to_cancelled`) and it is +served as described in [§5](#5-result--get-agent-kvjob_idresult). + +A success callback for an already-terminal job is therefore the one case where +finalize writes a result for a row it read as terminal. It is `CANCELLED`-only +and guarded in SQL on a blank `result_ref`, so a duplicate callback still +attaches nothing and deletes its own write — the idempotency the short-circuit +protected is unchanged. A **failure** callback for a cancelled job salvages +nothing (there is no result), and a `COMPLETED` or `FAILED` row is never given a +second look. + +What does *not* change: `status` stays `cancelled`, `completed_at` is untouched, +and `mark_terminal` remains the only way a job reaches a terminal state. The +salvage adds a pointer to work that was already billed; it cannot restatus +anything, and it only ever applies to a `CANCELLED` row (a `COMPLETED` or +`FAILED` row owns its own outcome). + +A caller who supplied `webhook_url` has already been sent `cancelled` by then, +so a **follow-up notification** carrying `result_available: true` is delivered +when this happens — see [§8](#8-webhook-delivery). + +> **This was reverted three times before it shipped, and the reason is worth +> knowing before you touch it.** `DELETE /agent-kv/{job_id}/` guarantees that a +> purged job cannot be resurrected, and it used to guarantee that *by relying on* +> the late finalize deleting its own orphaned write. Keeping the result removes +> exactly that cleanup, and every attempt to rebuild the guarantee from the job's +> own ref columns failed review: > -> This is not an oversight, it is an unresolved trade, and keeping the result -> was tried three times and reverted each time. The obstacle is that -> `DELETE /agent-kv/{job_id}/` guarantees a purged job cannot be resurrected -> precisely BY relying on a late finalize deleting its own write (see the -> comment opening `JobStatusView.delete`). Storing the result instead removes -> that cleanup, and every attempt to re-establish the guarantee from the job's -> own fields failed review: +> 1. Gate the attach on `result_ref=""` — a purged job could still serve a late +> result, because `DELETE` blanks both refs and leaves precisely the row that +> filter matches. +> 2. Also gate on a non-blank `input_ref` — **unsound.** `delete_job_files` +> deliberately *keeps* a ref whose object delete failed, so TTL cleanup has a +> handle to retry from; a non-blank `input_ref` therefore does not prove the +> job was never purged. +> 3. Also re-read the row inside `DELETE` — narrowed the window but left a +> storage orphan (cleanup can clear a ref it never deleted, and later sweeps +> select only jobs that still have one, so extracted data outlives its +> retention window) and left the cancellation webhook un-followed-up. > -> - Gating on `result_ref` alone let a purged job serve a late result. -> - Gating additionally on a non-blank `input_ref` did not help: `DELETE` -> deliberately KEEPS a ref whose file delete failed, so that it can be -> retried — meaning a non-blank `input_ref` does not prove the job was never -> deleted. -> - Re-reading the row inside `DELETE` narrowed the window but left a storage -> orphan (cleanup can clear a ref it never deleted, and later sweeps only -> select jobs that still have one), and left the cancellation webhook -> un-followed-up, so a webhook-driven caller never learns the answer arrived. -> -> Closing it needs an explicit "files purged" marker that `DELETE`, TTL cleanup -> and finalize all agree on, written unconditionally rather than inferred from -> refs, plus a follow-up notification. Tracked separately. The window is narrow, -> and the caller did ask to cancel. +> The common thread: `result_ref`/`input_ref` carry **three** meanings — never +> set, purged, and purge-attempted-delete-failed-retry-later — so purge state +> cannot be inferred from them. What shipped records it instead, on the +> `files_purged` column: written unconditionally by `DELETE` and by TTL cleanup +> before either attempts a delete, read by the attach (which refuses a purged +> row) and by the result endpoint (which refuses to *serve* one, covering the +> surviving-ref case no ref check can see), and both sides take the row lock so +> cleanup cannot interleave between its read and its blanking write. - Won the race (job was non-terminal): `200` `{"status": "cancelled"}`. - Already terminal: `409` `{"status": ""}`, lowercased (e.g. @@ -686,9 +733,32 @@ curl -X POST https://api.unstract.example/agent-kv/5b6e9b0a-.../cancel \ ## 7. Delete — `DELETE /agent-kv/{job_id}` -Deletes the result and any residual staged input immediately, and blanks both refs -(`204`, no body). The job row itself is retained (audit trail); a later `result` fetch -404s (blank `result_ref`), and status still works. +Deletes the result and any residual staged input immediately, blanks the refs whose +objects are confirmed gone, and marks the job `files_purged` (`204`, no body). The job +row itself is retained (audit trail); a later `result` fetch 404s, and status still +works. + +A few details that matter if you are changing this endpoint: + +- **On a non-terminal job it cancels first**, then purges. Cancelling before the + deletes is what stops a still-running job from carrying on after its files are + gone, and from writing a fresh result onto a job the caller has asked to destroy. +- **The `files_purged` marker is written unconditionally** — on every `DELETE`, + whatever the job's status, and *before* the object deletes are attempted, so a + delete that fails cannot leave it unset. It records the caller's intent, not the + outcome. It is what makes "this job was purged" answerable at all: a blank ref + can equally mean "nothing was ever staged", and a *non*-blank ref can mean + "purged, but the object delete failed and the ref is kept so TTL cleanup can + retry" (`storage.delete_job_files`). Nothing downstream may infer purge state + from the ref columns — see the note in [§6](#6-cancel--post-agent-kvjob_idcancel). +- **A ref whose object delete failed is kept**, deliberately, as that retry handle. + The response is still `204`: the job is terminal, the intent is recorded, and a + `5xx` here would only invite a retry of a `DELETE` that already did everything it + could. The result endpoint refuses such a job on the marker, so a surviving ref + never becomes a surviving read. +- **It re-reads the row under a lock** (`AgentKVJob.mark_files_purged`) immediately + before deleting, so a result attached by a late finalize in the meantime is purged + along with everything else, and one attempted afterwards is refused outright. ```bash curl -X DELETE https://api.unstract.example/agent-kv/5b6e9b0a-.../ \ @@ -697,11 +767,35 @@ curl -X DELETE https://api.unstract.example/agent-kv/5b6e9b0a-.../ \ ## 8. Webhook delivery -`webhook_url` (if given at submit) receives exactly one terminal-state POST: +`webhook_url` (if given at submit) receives one terminal-state POST: `{"job_id": "...", "status": "completed"|"failed"|"cancelled"}` — a fixed payload shape, no result content (spec §6.7, §7.1). Delivered from the `ide_callback` worker (`workers/ide_callback/agent_kv_tasks.py::_maybe_webhook`) only on a *fresh* finalize -(a duplicate/late finalize for an already-terminal job never re-fires it). +(a duplicate/late finalize for an already-terminal job does not re-fire it, with the +one exception below), or from `agent_kv_cancelled` when a cancel won the race +instead. + +**There is exactly one case of a second POST**, and it is additive: + +```json +{"job_id": "...", "status": "cancelled", "result_available": true} +``` + +It is sent when a cancel that had already been notified turns out to have lost the +race to a finished extraction, so the result was kept and is now readable +([§6](#6-cancel--post-agent-kvjob_idcancel)). Without it a webhook-driven caller — +who has no reason to poll a job they were told was cancelled — would never learn the +answer arrived. `result_available` is the only thing distinguishing it from the +cancellation POST they already have; a bare duplicate would carry no information at +all. It appears on no other notification, so a receiver that ignores unknown keys +simply sees a repeat of the cancellation. + +Nothing orders the two POSTs. The cancellation one is queued by the backend the +moment its cancel wins and delivered by a worker; the follow-up is sent inline by +the finalize callback. A receiver can therefore see the follow-up first and then the +plain `cancelled` — treat `result_available` as sticky, not as the state of the +world at delivery time. The result's readability does not depend on either POST +landing: `GET result` is the authority, and this notification only saves a poll. **SSRF controls, as actually implemented** (`workers/shared/utils/webhook_notify.py`, function `send_webhook`) — this corrects an earlier draft of this document, which @@ -725,8 +819,9 @@ own module docstring says so: elsewhere), but the Agent-KV caller (`_maybe_webhook`) never passes it — every Agent-KV webhook is https-only in practice. - **No redirects** (`allow_redirects=False`). -- **Fixed, minimal payload** (`{job_id, status}` only — see above) and the - **response body is never read** — only the status code is inspected. +- **Fixed, minimal payload** (`{job_id, status}`, plus `result_available` on the + one follow-up described above — never result content) and the **response body is + never read** — only the status code is inspected. - **Short timeout** (10 seconds). - **No retries.** One POST attempt only; any failure (host refused by the pre-check, timeout, connection error, non-2xx status) is logged and swallowed — @@ -751,10 +846,12 @@ production. - **Cancelled jobs' inputs intentionally ride TTL instead.** `JobCancelView` marks a job `CANCELLED` directly via `AgentKVJob.mark_terminal`, not through `FinalizeView` — so cancellation never triggers the completion-time delete above. A late finalize - call against an already-`CANCELLED` job also no-ops (it loses the terminal guard, - same as any duplicate). A cancelled job's staged input is therefore cleaned up the - ordinary way: by the TTL sweep once `expires_at` passes, or by an explicit - `DELETE /agent-kv/{job_id}`. + against an already-`CANCELLED` job does not trigger it either: it never wins the + terminal guard, and `_drop_staged_input` stays gated on a won guard, so even the + success callback that *does* store a result there + ([§6](#6-cancel--post-agent-kvjob_idcancel)) leaves the input alone. A cancelled + job's staged input is therefore cleaned up the ordinary way: by the TTL sweep once + `expires_at` passes, or by an explicit `DELETE /agent-kv/{job_id}`. - **Result retention**: `AGENT_KV_RESULT_TTL_DAYS` (default **7** days, D10's engineering default) — results are re-readable until then, then swept by the internal `ttl-cleanup` endpoint ([§6](#11-deploy-checklist)), which blanks @@ -762,6 +859,21 @@ production. defensively for any input that somehow outlives completion). The job row itself is never deleted (audit trail persists past TTL, only the object-store payloads are dropped). +- **TTL cleanup marks every row it processes `files_purged`**, on the same terms as + `DELETE` ([§7](#7-delete--delete-agent-kvjob_id)): before the deletes are + attempted, and whether or not they succeed. The sweep *is* a purge, so it owes + that marker — and without it, a result attached to a cancelled row by a late + finalize could land after the sweep had read the row and before it blanked the + refs, leaving the object in storage with its ref cleared. Since the sweep's own + candidate filter is `input_ref > "" OR result_ref > ""`, a row blanked that way + can never be reconsidered: extracted customer data past its retention window with + nothing pointing at it. The marker plus the row lock + (`AgentKVJob.mark_files_purged`) is what closes that. +- **A row whose object delete failed keeps its ref and is retried** from a reserved + slice of each later batch (`cleanup_failed_at`, see `maintenance.run_ttl_cleanup`). + Such a row is already `files_purged`, so the API treats it as purged while cleanup + is still working on the bucket — which is the point: the two facts are separate and + are recorded separately. - **`expires_at` is stamped at submit time**, not at completion — `SubmitView.post` sets `expires_at = timezone.now() + timedelta(days=AGENT_KV_RESULT_TTL_DAYS)` before dispatch even runs. **This is a real divergence from spec D10's framing** @@ -814,8 +926,15 @@ On `noop: true`, the executor **must**: 1. Stop doing any further work on that job immediately. 2. **Still call `FinalizeView`** (`POST .../jobs/{job_id}/finalize/`) with whatever - outcome it has — success or failure, it does not matter which, since the write is - going to no-op either way. + outcome it has — success or failure, it does not matter which: at pickup there is + no result yet, so the status write no-ops either way. + + *A `noop` on a LATER stage report is a different situation and the executor should + not conflate them.* By the final report the work is done and paid for, and a + success body carrying the full output is then stored on the cancelled job rather + than discarded ([§6](#6-cancel--post-agent-kvjob_idcancel)). So "report whatever + you have" is right at both ends of the run, but for different reasons: at pickup + because nothing can be lost, and at the end because something can. Step 2 is not optional. `FinalizeView`'s terminal-state guard (spec §5.4) correctly no-ops the status write for an already-terminal job — but its concurrency-slot diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index 65255ce61c..5ac699b7a1 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -272,22 +272,32 @@ def agent_kv_cancelled( return {"job_id": job_id, "status": "cancelled"} -def _send_webhook(url: str, job_id: str, status: str) -> bool: +def _send_webhook( + url: str, job_id: str, status: str, *, result_available: bool = False +) -> bool: """Deliver one terminal notification. Single place the SSRF waiver is read, so the cancel path cannot drift from the finalize path on what it permits. + + ``result_available`` adds one key to the otherwise fixed ``{job_id, + status}`` payload, and is set on exactly one notification: the follow-up for + a job cancelled after its extraction had already finished, whose result is + therefore readable even though the status is ``cancelled``. It is additive + -- a receiver that ignores unknown keys sees a repeat of the cancellation it + already got -- and it is the only thing that distinguishes the follow-up + from that first notification, which is why it is sent rather than a bare + duplicate: an identical second POST would carry no information at all. """ # Test/dev stacks only (e2e lane): waive the SSRF guards so a receiver on # the compose host is reachable. Unset/false in production. allow_insecure = os.environ.get( "AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE", "" ).lower() in ("1", "true", "yes") - return send_webhook( - url, - {"job_id": job_id, "status": status}, - allow_insecure=allow_insecure, - ) + payload: dict[str, Any] = {"job_id": job_id, "status": status} + if result_available: + payload["result_available"] = True + return send_webhook(url, payload, allow_insecure=allow_insecure) def _maybe_webhook(finalize_response: dict[str, Any], job_id: str) -> None: @@ -301,10 +311,21 @@ def _maybe_webhook(finalize_response: dict[str, Any], job_id: str) -> None: A job cancelled through the API is the "already terminal" case, and its notification is owned by ``agent_kv_cancelled``, enqueued by the backend at the moment its guarded cancel won. + + **One non-fresh finalize does still notify**, and it is the exception that + the ``result_attached`` flag exists for: the job was cancelled after its + extraction had already finished, so the backend kept the paid-for result on + the cancelled row instead of discarding it. The cancellation webhook went + out the moment that cancel won, and nothing follows it -- so a + webhook-driven caller, who has no reason to poll a job they were told was + cancelled, would never learn the answer is there to read. See + ``AgentKVJob.attach_result_to_cancelled``. """ + url = finalize_response.get("webhook_url") or "" if not finalize_response.get("finalized"): + if url and finalize_response.get("result_attached"): + _notify_result_available(url, job_id) return - url = finalize_response.get("webhook_url") or "" if not url: return status = finalize_response.get("status", "") @@ -324,3 +345,26 @@ def _maybe_webhook(finalize_response: dict[str, Any], job_id: str) -> None: job_id, status, ) + + +def _notify_result_available(url: str, job_id: str) -> None: + """Follow up a cancellation notification that is now out of date. + + Sent only when ``FinalizeView`` reports ``result_attached``: the job was + cancelled while its extraction was finishing, the usage was already flushed + and billed, and the result is therefore kept and served by + ``GET /agent-kv/{job_id}/result`` alongside ``status: "cancelled"``. + + Logged at ERROR rather than raised on failure, matching the fresh-finalize + branch above and for the same reason: the job is already terminal and its + result already stored, so raising would only re-run a finalize whose work is + done. The result stays readable either way -- this notification is what + saves the caller a poll, not what makes the answer available. + """ + if not _send_webhook(url, job_id, "cancelled", result_available=True): + logger.error( + "agent-kv result-available follow-up not delivered for job %s; the " + "caller was told `cancelled` and will not learn that the result was " + "kept unless they poll the result endpoint", + job_id, + ) diff --git a/workers/ide_callback/tests/test_agent_kv_callbacks.py b/workers/ide_callback/tests/test_agent_kv_callbacks.py index d78f7c670e..b6d338f96c 100644 --- a/workers/ide_callback/tests/test_agent_kv_callbacks.py +++ b/workers/ide_callback/tests/test_agent_kv_callbacks.py @@ -572,3 +572,128 @@ def test_a_failed_cancellation_webhook_raises_so_it_retries(self, mock_send): }, throw=True, ) + + +class TestTheResultAvailableFollowUp: + """A cancellation notification that turns out to be incomplete. + + The job was cancelled while its extraction was finishing, so the engine had + already flushed its OCR and LLM usage and the backend keeps the paid-for + result on the cancelled row rather than discarding it + (`AgentKVJob.attach_result_to_cancelled`). The cancellation webhook went out + the moment that cancel won and nothing follows it -- so a webhook-driven + caller, who has no reason to poll a job they were told was cancelled, would + never learn the answer is there. `result_attached` is the one case where a + NON-fresh finalize still owes a notification. + """ + + @patch(_PATCH_SEND_WEBHOOK, return_value=True) + def test_a_salvaged_result_notifies_even_though_finalized_is_false(self, mock_send): + from ide_callback import agent_kv_tasks as akt + + akt._maybe_webhook( + { + "finalized": False, + "result_attached": True, + "webhook_url": "https://hook.example/x", + "status": "cancelled", + }, + "job-1", + ) + + # `result_available` is what distinguishes this from the cancellation + # POST the caller already got. A bare duplicate would carry no + # information at all -- same job_id, same status. + mock_send.assert_called_once_with( + "https://hook.example/x", + {"job_id": "job-1", "status": "cancelled", "result_available": True}, + allow_insecure=False, + ) + + @patch(_PATCH_SEND_WEBHOOK, return_value=True) + def test_an_ordinary_non_fresh_finalize_still_notifies_nobody(self, mock_send): + """The guard the flag replaces must not widen: a duplicate finalize, or + a cancel with nothing salvaged, is already accounted for by whoever won. + """ + from ide_callback import agent_kv_tasks as akt + + akt._maybe_webhook( + { + "finalized": False, + "result_attached": False, + "webhook_url": "https://hook.example/x", + "status": "completed", + }, + "job-1", + ) + # And a response from a backend that predates the flag entirely. + akt._maybe_webhook( + { + "finalized": False, + "webhook_url": "https://hook.example/x", + "status": "completed", + }, + "job-1", + ) + + assert not mock_send.called + + @patch(_PATCH_SEND_WEBHOOK, return_value=True) + def test_no_webhook_url_means_no_follow_up(self, mock_send): + from ide_callback import agent_kv_tasks as akt + + akt._maybe_webhook( + {"finalized": False, "result_attached": True, "webhook_url": ""}, + "job-1", + ) + + assert not mock_send.called + + @patch(_PATCH_SEND_WEBHOOK, return_value=False) + def test_an_undelivered_follow_up_is_logged_not_raised(self, mock_send, caplog): + """Logged for the same reason as the fresh-finalize branch: the job is + already terminal and its result already stored, so raising would only + re-run work that is done. The result stays readable either way -- the + notification saves the caller a poll, it does not make the answer + available. + """ + import logging + + from ide_callback import agent_kv_tasks as akt + + with caplog.at_level(logging.ERROR, logger="ide_callback.agent_kv_tasks"): + akt._maybe_webhook( + { + "finalized": False, + "result_attached": True, + "webhook_url": "https://hook.example/x", + "status": "cancelled", + }, + "job-1", + ) + + assert "not delivered" in caplog.text + assert "job-1" in caplog.text + + @patch(_PATCH_SEND_WEBHOOK, return_value=True) + def test_the_fixed_payload_is_unchanged_for_every_other_notification(self, mock_send): + """`result_available` is additive and appears on exactly one + notification. Every other POST keeps the frozen `{job_id, status}` + shape (spec section 6.7/7.1). + """ + from ide_callback import agent_kv_tasks as akt + + akt._maybe_webhook( + { + "finalized": True, + "webhook_url": "https://hook.example/x", + "status": "completed", + }, + "job-1", + ) + + mock_send.assert_called_once_with( + "https://hook.example/x", + {"job_id": "job-1", "status": "completed"}, + allow_insecure=False, + ) diff --git a/workers/shared/api/internal_client.py b/workers/shared/api/internal_client.py index 329659dd30..e832c609d0 100644 --- a/workers/shared/api/internal_client.py +++ b/workers/shared/api/internal_client.py @@ -1634,7 +1634,13 @@ def agent_kv_finalize( usage_summary: Optional usage/cost summary to persist (success only). Returns: - Backend response: ``{"finalized": bool, "webhook_url": str, "status": str}``. + Backend response: ``{"finalized": bool, "webhook_url": str, + "status": str, "reason": str, "result_attached": bool}``. + ``result_attached`` is True only when ``finalized`` is False because + a cancel won the race, and the finished extraction's result was kept + on the cancelled row rather than discarded -- the one non-fresh + finalize that still owes the caller a notification (see + ``ide_callback.agent_kv_tasks._maybe_webhook``). """ payload: dict[str, Any] = {"org_id": org_id, "success": success} if success: From ffe267b4051904bd362a88664a5912bdababfe53 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 10:27:17 +0530 Subject: [PATCH 29/54] UN-4232 [FIX] Delete the Agent-KV periodics whatever owner the chart left on them 0007 removes the two rows 0004 seeded, but only WHERE pg_owned = False, to spare a self-hosted operator who had adopted them. Its premise was that only a human ever sets that flag. The cloud chart's backend.pgScheduleMirror hook runs converge_pg_scheduler --periodics on every install, upgrade and rollback; that calls mirror_pg_periodic_tasks --adopt with no names, which is PgPeriodicTask.objects.all(); and validate-pg-worker-fleet.yaml fails the render with the hook off, so it is not optional. The guard therefore spares precisely the rows that are double-firing. Found live on vishnu-unstract-dev running snapshot.415: agent_kv_sweep pg_owned=t, last_run 11:00:02, next_run 11:10 under the PG scheduler, next to the CronJob's every-15-minutes tick. They meet at :30 -- the collision the chart's offset comment says it avoids -- and ttl-cleanup ran daily on one owner and hourly on the other. 0009 repeats the delete scoped by name alone. It has to be a new migration for the same reason 0007 is not an edit to 0004: 0007 is recorded as applied wherever the problem exists, and Django never re-runs it. Reverse is a documented no-op -- 0007's reverse already restores the pair for a rollback that goes back past it, and restoring them here would put the second owner back. The self-hosted operator the guard protected cannot exist at the point either migration runs: 0004 and its removals ship in one release, so there was never a published version carrying these names without a delete. An operator who creates their own rows afterwards is untouched, because 0009 is already recorded applied. Verified against a real database, not only the fakes: rolled a fresh schema back to 0006, flipped every row to pg_owned=true as the hook does, migrated to 0007 (both rows survive -- the bug), then to 0009 (both gone, the five dashboard_metrics periodics intact). Co-Authored-By: Claude Opus 5 --- .../migrations/0004_pg_periodic_tasks.py | 9 +- .../0007_remove_agent_kv_periodic_tasks.py | 20 ++ .../0009_drop_agent_kv_periodic_tasks.py | 115 ++++++++ .../tests/test_periodic_tasks_migration.py | 272 ++++++++++++++++-- docs/agent-kv-api.md | 40 ++- 5 files changed, 424 insertions(+), 32 deletions(-) create mode 100644 backend/agent_kv/migrations/0009_drop_agent_kv_periodic_tasks.py diff --git a/backend/agent_kv/migrations/0004_pg_periodic_tasks.py b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py index 083e6aa5f0..5bc206ba7f 100644 --- a/backend/agent_kv/migrations/0004_pg_periodic_tasks.py +++ b/backend/agent_kv/migrations/0004_pg_periodic_tasks.py @@ -1,12 +1,15 @@ -"""Schedule the two Agent-KV maintenance periodics. **SUPERSEDED by 0007.** +"""Schedule the two Agent-KV maintenance periodics. **SUPERSEDED by 0007/0009.** -Read `0007_remove_agent_kv_periodic_tasks.py` before acting on anything here. +Read `0007_remove_agent_kv_periodic_tasks.py` and +`0009_drop_agent_kv_periodic_tasks.py` before acting on anything here. In short: the premise stated below -- that nothing anywhere fired these two tasks, "no ``PgPeriodicTask``, no beat entry, no CronJob" -- was already false when it was written. The cloud chart's `backend.agentKvCronJobs` ran the same two management commands, added earlier on this same branch, so this migration registered a SECOND owner for one schedule. 0007 deletes these rows and the -CronJob is the sole owner. +CronJob is the sole owner -- except that 0007 spares rows the chart's +schedule-mirror hook has already adopted, which on cloud is both of them, so +0009 repeats the delete without the ownership scope. This file is deliberately left seeding rather than rewritten into a no-op. Django records a migration as applied and never re-runs it, so editing this diff --git a/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py index 8879cad572..f1be42e581 100644 --- a/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py +++ b/backend/agent_kv/migrations/0007_remove_agent_kv_periodic_tasks.py @@ -1,5 +1,25 @@ """Remove the Agent-KV maintenance periodics -- the CronJob owns the schedule. +**The ``pg_owned=False`` scope below is wrong on cloud, and +``0009_drop_agent_kv_periodic_tasks`` finishes the job. Read that first.** In +short: the premise this file argues from -- "nothing automated ever sets +``pg_owned=True``" -- is false. The chart's ``backend.pgScheduleMirror`` hook +runs ``converge_pg_scheduler --periodics`` on every install, upgrade and +rollback, and its blanket ``--adopt`` flips every row in ``pg_periodic_task``, +these two included. So on cloud the guard spares exactly the rows that are +double-firing, which was then observed live on a dev namespace. 0009 deletes +by name alone, and explains why the self-hosted operator this guard protects +cannot exist at the point either migration runs. + +This file is left as it was, for the same reason it exists rather than being +an edit to 0004: it is recorded as applied in every environment that has the +problem, so changing its predicate would reach none of them. + +Everything below is the original reasoning, kept for the record. + +---- + + Migration 0004 seeded two ``PgPeriodicTask`` rows (``agent_kv.sweep`` at ``*/10``, ``agent_kv.ttl_cleanup`` at ``17 3 * * *``) on the stated premise that nothing anywhere fired the two maintenance commands -- "no diff --git a/backend/agent_kv/migrations/0009_drop_agent_kv_periodic_tasks.py b/backend/agent_kv/migrations/0009_drop_agent_kv_periodic_tasks.py new file mode 100644 index 0000000000..de5ec1f285 --- /dev/null +++ b/backend/agent_kv/migrations/0009_drop_agent_kv_periodic_tasks.py @@ -0,0 +1,115 @@ +"""Finish what 0007 started: drop the Agent-KV periodics whatever their owner. + +0007 deletes the two rows 0004 seeded, but only ``WHERE pg_owned = False``. On +a cloud deployment that guard spares **exactly** the rows that are +double-firing, so the collision 0007 exists to prevent is live in every +namespace that applied 0004 before 0007 landed. Observed on +``vishnu-unstract-dev`` running snapshot.415, with both owners in place:: + + pg_periodic_task agent_kv_sweep pg_owned=t */10 + last_run = 2026-10-12 11:00:02 + next_run = 2026-10-12 11:10:00 + CronJob unstract-backend-agent-kv-sweep-cronjob */15 (ran ~10:57) + pod/unstract-worker-pg-scheduler 1/1 Running + +Both tick. They meet at ``:30``, which is the collision the chart's offset +comment says it avoids, and ttl-cleanup disagrees with itself by 24x -- daily +on the PG row, hourly on the CronJob. + +**Why 0007's guard does not hold: its premise is false.** 0007 reasons that +``pg_owned=True`` can only be a self-hosted operator's deliberate act, because +"nothing automated ever sets ``pg_owned=True``". Something does. The cloud +chart's ``backend.pgScheduleMirror`` hook runs ``converge_pg_scheduler +--periodics`` on post-install, post-upgrade AND post-rollback, which calls +``mirror_pg_periodic_tasks --adopt`` with no names -- and an empty name list +means ``PgPeriodicTask.objects.all()`` (``_set_ownership``), every row in the +table. ``templates/validate-pg-worker-fleet.yaml`` fails the render if that +hook is disabled, so it is not an opt-in an environment can decline. + +That is the table's contract, not a bug in the hook: ``pg_periodic_task`` is +where the PG scheduler's rollout looks for what it owns, and +``dashboard_metrics`` seeds rows there inert precisely so the hook can turn +them on. A row in this table is a declaration that the PG scheduler should +fire it. The mistake was 0004 putting the Agent-KV schedules there while the +chart was already running them as CronJobs. + +**So ``pg_owned=False`` is not a stable state here.** Setting the flag back +would last until the next ``helm upgrade`` re-adopted the row, which means +deletion is the only end state that holds. This is also why the fix cannot be +a values change or a one-off ``UPDATE``. + +**Why the self-hosted operator 0007 protects cannot exist.** 0004 and its +removals ship in the SAME release; no released version has ever carried 0004 +without one. At the moment this runs, therefore, the only rows named +``agent_kv_sweep`` / ``agent_kv_ttl_cleanup`` are rows 0004 seeded minutes (or, +for a dev namespace, weeks) earlier -- there was no published release in which +an operator could have found these names and adopted them deliberately. + +The operator 0007 describes is a FUTURE one, who upgrades to this release and +then follows ``docs/agent-kv-api.md`` §12 and creates their own rows. Django +records a migration as applied and never re-runs it, so by the time they do, +this migration is already recorded and cannot touch what they create. The +guard protects nobody and spares the one case that needs fixing. + +**Why a new migration rather than an edit to 0007.** The same reason 0007 is +not an edit to 0004, and the same mistake this branch has now made twice: +0007 is recorded as applied in every environment that has an adopted row, so +rewriting its predicate reaches none of them. The rule is cheap to restate -- +a migration already applied is a historical record, and the only way to change +what a deployed database looks like is to add to the chain. + +0007 stays as it is. Its docstring carries a pointer here. + +**Reverse is a no-op, deliberately.** 0007's reverse already re-creates both +rows (inert) for a rollback that goes back past it, which is the state the +previous release shipped. Re-creating them here as well would mean a rollback +to 0008 restored the second owner -- turning the rollback into the double-fire +this migration removes -- and restoring them *adopted*, which is what forward +actually found, would restore it in its firing form. There is nothing this +step can put back that is safe to put back. + +Self-hosted operators enabling the Agent-KV API still have to schedule +``manage.py agent_kv_sweep`` and ``manage.py agent_kv_ttl_cleanup`` +themselves; see 0007's docstring and ``docs/agent-kv-api.md`` §12. Doing it +with their own ``PgPeriodicTask`` rows is still fine -- and on a deployment +with no CronJob, having the mirror hook adopt them is the correct outcome. +""" + +import importlib + +from django.db import migrations + +#: Read from 0004 rather than re-declared, so this cannot drift into deleting a +#: different set than was seeded. `importlib` because the module name starts +#: with a digit. Same source 0007 reads. +PG_PERIODIC_TASKS = importlib.import_module( + "agent_kv.migrations.0004_pg_periodic_tasks" +).PG_PERIODIC_TASKS + + +def drop_pg_periodic_tasks(apps, schema_editor): + """Delete both rows by name, whatever ``pg_owned`` says. + + Scoped by name and nothing else. The name scope is load-bearing in the + other direction from 0007's ownership scope: this table is shared with the + five ``dashboard_metrics`` periodics, which the PG scheduler legitimately + owns, so an unscoped delete would take the metrics pipeline down with it. + + Idempotent: 0007 has normally already removed the inert pair, and a fresh + install matches nothing. + """ + # snake_case for the historical model, as in 0004 and 0007: sonar's S117 + # reads the usual CamelCase local as a naming violation. + periodic_task = apps.get_model("pg_queue", "PgPeriodicTask") + names = [spec["name"] for spec in PG_PERIODIC_TASKS] + periodic_task.objects.filter(name__in=names).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("agent_kv", "0008_agentkvjob_files_purged"), + ] + + operations = [ + migrations.RunPython(drop_pg_periodic_tasks, migrations.RunPython.noop), + ] diff --git a/backend/agent_kv/tests/test_periodic_tasks_migration.py b/backend/agent_kv/tests/test_periodic_tasks_migration.py index eac38e608a..c5650f1d56 100644 --- a/backend/agent_kv/tests/test_periodic_tasks_migration.py +++ b/backend/agent_kv/tests/test_periodic_tasks_migration.py @@ -15,8 +15,20 @@ put a ``*/10`` sweep against the chart's ``*/15`` -- colliding at ``:30``, contending on the same job rows -- with TTL cadences disagreeing 24x. -So **0007** deletes those rows and the CronJob is the sole owner. 0004 is left -seeding, deliberately: Django records a migration as applied and never re-runs +So **0007** deletes those rows and the CronJob is the sole owner -- except +that 0007 scopes its delete to ``pg_owned=False``, to spare a self-hosted +operator who had adopted the rows, on the stated premise that nothing +automated ever sets that flag. **That premise is false, and the collision +above stopped being hypothetical.** The cloud chart's +``backend.pgScheduleMirror`` hook runs ``converge_pg_scheduler --periodics`` +on every install, upgrade and rollback; its blanket ``--adopt`` flips every +row in ``pg_periodic_task``; and ``validate-pg-worker-fleet.yaml`` refuses to +render with the hook off. Deploying snapshot.415 to a dev namespace found both +rows ``pg_owned=t`` with the PG scheduler firing ``agent_kv_sweep`` at +``*/10`` alongside the CronJob's ``*/15``. 0007's guard spares precisely the +rows that are double-firing, so **0009** repeats the delete by name alone. + +0004 is left seeding, and 0007 is left guarding, deliberately: Django records a migration as applied and never re-runs it, so editing 0004 would have had no effect in the environments that actually have the rows (every dev namespace that applied it) while silently rewriting history for fresh ones. The first cut of this fix did exactly that and claimed @@ -25,10 +37,14 @@ already-seeded one just deletes, and both land on zero rows with the five dashboard_metrics periodics untouched. -What these tests defend is that arrangement, in the three ways it can be -undone: someone pointing 0007's forward operation at the restore function, -someone dropping its reverse and making it one-way, or someone "simplifying" -the removal back into an edit of 0004. +What these tests defend is that arrangement, in the ways it can be undone: +someone pointing 0007's forward operation at the restore function, someone +dropping its reverse and making it one-way, someone "simplifying" either +removal back into an edit of an earlier migration, or someone re-introducing +an ownership scope on 0009. The replay tests at the end are the behavioural +ones -- they run the whole chain against a fake row store and assert the end +state is zero ``agent_kv*`` rows whichever owner the hook left behind, with +the ``dashboard_metrics`` neighbours intact. The name/queue assertions are kept rather than deleted -- they still guard the reverse path, and the same wire names are what the CronJob's management @@ -37,6 +53,8 @@ import importlib import inspect +import pathlib +import re import pytest from django.db import migrations @@ -46,6 +64,7 @@ MIGRATION = importlib.import_module( "agent_kv.migrations.0007_remove_agent_kv_periodic_tasks" ) +DROP = importlib.import_module("agent_kv.migrations.0009_drop_agent_kv_periodic_tasks") #: Wire names registered by `@worker_task(name=...)` in #: `workers/scheduler/agent_kv_tasks.py`. Duplicated as literals on purpose: @@ -144,27 +163,23 @@ def test_the_migration_is_reversible(): assert op.reverse_code is not migrations.RunPython.noop -def test_the_removal_spares_a_schedule_the_operator_has_taken_OWNERSHIP_of(): - """`pg_owned=True` means a self-hosted operator is relying on this row. +def test_0007_keeps_the_predicate_it_was_applied_with(): + """0007 is history now; widening it in place would reach nobody. - These rows are seeded inert, so deleting an inert one removes the - accidental second owner and nothing else. But a self-hosted operator - running the PG scheduler has every reason to flip `pg_owned: True` -- the - docs tell them to, and there is no CronJob outside the cloud chart to - replace it. Deleting by NAME alone would silently remove their only - maintenance mechanism: stuck jobs stop being swept and hold their - concurrency slots until the 6 h Redis TTL, and expired customer documents - stay in the bucket indefinitely, with nothing reporting either. - - Verified end to end against a real database as well (flip one row to - `pg_owned=True`, migrate, and exactly that row survives); this pins the - predicate so the scope cannot quietly widen back to name-only. + Its `pg_owned=False` scope turned out to be wrong on cloud -- the chart's + schedule-mirror hook adopts every row in `pg_periodic_task` on every + sync, so the guard spares exactly the pair that is double-firing. The fix + for that is 0009, NOT an edit here: 0007 is recorded as applied in every + environment that has an adopted row, so changing this predicate would + change only what a fresh install does, while the environments with the + problem keep it. That is the same mistake the first cut of 0007 made + against 0004, and this pins it shut. """ source = inspect.getsource(MIGRATION.remove_pg_periodic_tasks) assert "pg_owned=False" in source, ( - "the delete must spare rows an operator has taken ownership of; " - "name-only scoping strands a self-hosted deployment's maintenance" + "0007 has already been applied; widening its scope here fixes nothing " + "in the environments that have the rows. Add a migration instead." ) @@ -256,7 +271,10 @@ def test_the_restored_rows_are_inert(): source = inspect.getsource(MIGRATION.restore_pg_periodic_tasks) assert '"pg_owned": False' in source - assert "update_or_create" in source, "re-running must stay idempotent" + # Idempotency is `get_or_create`, pinned by the test above. It used to be + # asserted here as a bare `"update_or_create" in source`, which passed on + # the docstring's own explanation of why that call is NOT used -- a check + # that could never fail, standing in for one that mattered. def test_restored_rows_are_platform_wide(): @@ -268,3 +286,211 @@ def test_restored_rows_are_platform_wide(): assert '"org_id": ""' in source assert '"enabled": True' in source + + +# --------------------------------------------------------------------------- +# 0009: the delete that is not scoped by ownership. +# --------------------------------------------------------------------------- + + +def _drop_ops() -> list: + return [ + op for op in DROP.Migration.operations if isinstance(op, migrations.RunPython) + ] + + +def test_the_drop_still_has_a_runpython_operation(): + """Same empty-operations failure mode 0007 is guarded against.""" + assert len(_drop_ops()) == 1, DROP.Migration.operations + + +def test_the_drop_runs_the_delete_forward(): + assert _drop_ops()[0].code is DROP.drop_pg_periodic_tasks + + +def test_the_drop_is_a_new_migration_on_top_of_the_leaf(): + """Not an edit to 0007, for the reason 0007 is not an edit to 0004. + + 0007 is recorded as applied in every environment holding an adopted row. + Rewriting its predicate reaches none of them -- it changes only what a + fresh install does, which is the one case that was never broken. + """ + assert DROP is not MIGRATION + assert ("agent_kv", "0008_agentkvjob_files_purged") in DROP.Migration.dependencies + + +def test_the_drop_restores_nothing_on_reverse(): + """Reversible, but as a no-op -- and that is the decision, not an omission. + + 0007's reverse already re-creates both rows inert for a rollback that goes + back past it, which is what the previous release shipped. Re-creating them + here too would mean rolling back to 0008 restored the second owner; doing + it faithfully (restoring them ADOPTED, which is what forward found on + cloud) would restore it in its firing form. + """ + op = _drop_ops()[0] + + assert op.reverse_code is migrations.RunPython.noop + assert op.reverse_code is not None, "a None reverse makes the release one-way" + + +def test_the_drop_targets_exactly_what_the_seeder_created(): + assert DROP.PG_PERIODIC_TASKS is SEEDER.PG_PERIODIC_TASKS + + +# --------------------------------------------------------------------------- +# Replaying the chain. These are the behavioural tests: everything above pins +# the shape of a file, these pin what a database ends up looking like. +# --------------------------------------------------------------------------- + + +class _Selection: + """What a `.filter(...)` returns: just enough queryset to be deleted.""" + + def __init__(self, store: "_Rows", names: list[str]): + self._store = store + self._names = names + + def delete(self): + for name in self._names: + self._store.rows.pop(name, None) + return len(self._names), {} + + def exists(self) -> bool: + return bool(self._names) + + +class _Rows: + """A dict standing in for one model's table, keyed by `name`.""" + + def __init__(self, rows: dict | None = None): + self.rows: dict[str, dict] = {k: dict(v) for k, v in (rows or {}).items()} + + def update_or_create(self, name=None, defaults=None, **_kwargs): + self.rows.setdefault(name, {}).update(defaults or {}) + return self.rows[name], True + + def get_or_create(self, name=None, defaults=None, **_kwargs): + created = name not in self.rows + if created: + self.rows[name] = dict(defaults or {}) + return self.rows[name], created + + def filter(self, name=None, name__in=None, **predicates): + if name__in is not None: + candidates = list(name__in) + elif name is not None: + candidates = [name] + else: + candidates = list(self.rows) + selected = [ + candidate + for candidate in candidates + if candidate in self.rows + and all( + self.rows[candidate].get(field) == value + for field, value in predicates.items() + ) + ] + return _Selection(self, selected) + + +class _Apps: + def __init__(self, seeded: dict | None = None): + self.pg = _Rows(seeded) + self.other = _Rows() + + def get_model(self, app_label, model_name): + is_periodic_task = (app_label, model_name) == ("pg_queue", "PgPeriodicTask") + target = self.pg if is_periodic_task else self.other + return type("_M", (), {"objects": target}) + + +#: The five periodics this table is shared with, read from their own migration +#: rather than copied, so a rename there cannot leave this testing names that +#: no longer exist. +_NEIGHBOURS = { + spec["name"]: {"pg_owned": False, "enabled": True} + for spec in importlib.import_module( + "dashboard_metrics.migrations.0004_pg_periodic_tasks" + ).PG_PERIODIC_TASKS +} + + +def _agent_kv_migration_modules() -> list[str]: + directory = pathlib.Path(SEEDER.__file__).parent + stems = sorted( + path.stem for path in directory.glob("*.py") if re.match(r"^\d{4}_", path.stem) + ) + assert stems, "no migrations discovered -- the glob is wrong, not the app" + return [f"agent_kv.migrations.{stem}" for stem in stems] + + +def _replay(*, hook_adopts: bool) -> dict[str, dict]: + """Apply every agent_kv data migration in order; return the surviving rows. + + `hook_adopts` reproduces what the cloud chart does between releases: the + `backend.pgScheduleMirror` post-upgrade hook runs `converge_pg_scheduler + --periodics`, whose blanket `--adopt` flips `pg_owned` on EVERY row in + `pg_periodic_task` (`mirror_pg_periodic_tasks._set_ownership` with an empty + name list is `PgPeriodicTask.objects.all()`). It is applied after 0004 -- + the window a dev namespace actually sat in, having deployed the seeding + release before the removals existed. + """ + apps = _Apps(_NEIGHBOURS) + for dotted in _agent_kv_migration_modules(): + for op in importlib.import_module(dotted).Migration.operations: + if isinstance(op, migrations.RunPython): + op.code(apps, None) + if hook_adopts and dotted.endswith("0004_pg_periodic_tasks"): + for row in apps.pg.rows.values(): + row["pg_owned"] = True + return apps.pg.rows + + +def test_the_replay_is_not_vacuous(): + """Guards the guard: every assertion below is about rows being ABSENT, so a + harness that silently applied nothing would make all of them pass. + """ + apps = _Apps(_NEIGHBOURS) + seeder_op = [ + op for op in SEEDER.Migration.operations if isinstance(op, migrations.RunPython) + ][0] + seeder_op.code(apps, None) + + assert set(apps.pg.rows) == set(_NEIGHBOURS) | { + "agent_kv_sweep", + "agent_kv_ttl_cleanup", + } + + +def test_a_fresh_install_ends_with_no_agent_kv_periodics(): + """Seed, then delete, then delete again. 0004 is honest and costs two writes.""" + survivors = _replay(hook_adopts=False) + + assert not [name for name in survivors if name.startswith("agent_kv")] + + +def test_rows_the_mirror_hook_adopted_are_removed_TOO(): + """The regression 0009 exists for. + + A namespace that deployed the seeding release and was then `helm upgrade`d + had both rows flipped to `pg_owned=True` by the schedule-mirror hook, so + 0007's `pg_owned=False` delete matched neither and the PG scheduler fired + `agent_kv_sweep` at `*/10` against the CronJob's `*/15`. If this fails, + an ownership scope has come back and that double-fire is live again. + """ + survivors = _replay(hook_adopts=True) + + assert not [name for name in survivors if name.startswith("agent_kv")] + + +@pytest.mark.parametrize("hook_adopts", [False, True]) +def test_the_dashboard_metrics_neighbours_are_untouched(hook_adopts): + """The delete is scoped by NAME, and that scope is the load-bearing one now + that the ownership scope is gone. `PgPeriodicTask.objects.all().delete()` + here would take the metrics pipeline down with it. + """ + survivors = _replay(hook_adopts=hook_adopts) + + assert set(_NEIGHBOURS) <= set(survivors) diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index efd9bec4c4..18cda16486 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -1021,18 +1021,46 @@ beyond `docker compose up`: of registering a `PgPeriodicTask` row. **The CronJob is the only owner.** Migration `0004_pg_periodic_tasks` once also - seeded two `PgPeriodicTask` rows for the same two tasks; it now removes them. - They were seeded `pg_owned: False` so nothing double-fired, but the flag exists - to be flipped, and a flip would have run a `*/10` sweep against the chart's - `*/15` — colliding at `:30` on the same rows — with TTL cadences disagreeing 24x. - Only the CronJob side carries `concurrencyPolicy: Forbid` and a deadline. + seeded two `PgPeriodicTask` rows for the same two tasks. `0007` deletes them and + `0009` finishes the job; the paragraph below is why it takes two. + + The rows were seeded `pg_owned: False`, and `0007` scoped its delete to that flag + so a self-hosted operator who had deliberately adopted them was not stranded. The + premise was that only a human ever sets `pg_owned: True`. **It is not.** The cloud + chart's `backend.pgScheduleMirror` hook runs `converge_pg_scheduler --periodics` on + every install, upgrade *and* rollback; that calls `mirror_pg_periodic_tasks --adopt` + with no names, which is `PgPeriodicTask.objects.all()`; and + `templates/validate-pg-worker-fleet.yaml` refuses to render with the hook off, so + it is not optional. Putting a row in `pg_periodic_task` on a chart-managed + deployment *is* asking the PG scheduler to own it — which is the mechanism + `dashboard_metrics` relies on, and the reason `0004` should never have used it for + schedules the chart was already running as CronJobs. + + Deploying snapshot.415 to a dev namespace found both halves live: `agent_kv_sweep` + at `pg_owned=t` with `last_run 11:00:02 / next_run 11:10` under the PG scheduler, + alongside the CronJob's `*/15` tick — the `:30` collision the chart's offset comment + says it avoids, and ttl-cleanup disagreeing with itself 24x (daily on the row, + hourly on the CronJob). `0007`'s guard spares precisely the rows that are + double-firing, so `0009` repeats the delete by name alone. It has to be a new + migration: `0007` is recorded as applied wherever the problem exists, and Django + never re-runs it. + + Note also that `pg_owned: False` is not a stable state on a chart-managed + deployment — the next `helm upgrade` adopts the row again — so deleting the row is + the only end state that holds. Only the CronJob side carries + `concurrencyPolicy: Forbid` and a deadline. > **Self-hosted OSS: you must schedule these two yourself.** There is no CronJob > outside the cloud chart and no migration registers them any more. Run > `manage.py agent_kv_sweep` and `manage.py agent_kv_ttl_cleanup` from cron, a > systemd timer, or your own `PgPeriodicTask` rows with `pg_owned: True` — the > scheduler proxy tasks `agent_kv.sweep`/`agent_kv.ttl_cleanup` are still - > registered and still work. Without them a stranded job holds its concurrency + > registered and still work. `0009` cannot delete rows you create, because it is + > already recorded as applied by the time you create them; and on an on-prem helm + > install (`agentKvCronJobs.enabled: false`) the schedule-mirror hook adopting them + > is exactly what you want, since nothing else fires them. Create them under your + > own names if you would rather not share names with a retired migration. + > Without them a stranded job holds its concurrency > slot until the 6 h Redis TTL, and `AGENT_KV_RESULT_TTL_DAYS` is advisory: > staged documents and results stay in the bucket indefinitely, which is a > retention failure, not just a disk-usage one. From 138a09ac403dcd22565086532a7dd57aba2ac59b Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 10:41:35 +0530 Subject: [PATCH 30/54] UN-4232 [FIX] Stop a doomed salvage writing a result file it cannot keep `FinalizeView._salvage` wrote the result to object storage first and asked whether it was allowed to keep it second. For a callback redelivered after the result was already attached, or one arriving after the caller DELETEd the job, the answer is always no -- so every such callback wrote a file, had `attach_result_to_cancelled` refuse it, and then depended on `delete_result_file` to take it back out. That delete is best-effort by construction: it logs and returns, and no ref is persisted anywhere to retry from, so one failure left a customer's extracted output in the bucket with nothing pointing at it, after a purge or past the retention window. Those are the ordinary cases under at-least-once delivery, not races. The attach conditions are now declared once, as `AgentKVJob.ATTACH_GUARD`, and applied twice: by the filtered UPDATE that decides, and by `can_attach_result`, which `_salvage` reads BEFORE writing anything. One definition because the two must not drift -- looser writes files the guard refuses, tighter silently drops a paid-for result, which is the defect the salvage exists to fix. Tests assert the two answers agree for every clause. The read is advisory; the UPDATE still decides. For the genuine race that remains -- and for `_complete`, which has always had it -- `delete_job_files` now reaps the job's own directory once both named refs are confirmed gone, so an unreferenced leftover is collected when the job is deleted or expires. It runs last and best-effort, so it cannot change which refs the caller blanks, and it is skipped when a named delete failed: that ref is the retry handle and the file it points at is still wanted. Not closed, and said plainly in the code and in docs section 6: TTL candidacy comes from the ref columns, so an orphan created after a row has been fully cleaned is still unreachable. That needs a durable marker outliving the refs. Mutation-checked: removing the pre-flight fails both redelivery tests and nothing else; removing the reap fails exactly its own test. Greptile P1 on #2317. 356 agent_kv tests pass. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/internal_views.py | 30 +++++ backend/agent_kv/models.py | 39 +++++- backend/agent_kv/storage.py | 69 +++++++++- .../tests/test_cancelled_result_salvage.py | 120 +++++++++++++++--- backend/agent_kv/tests/test_storage.py | 103 +++++++++++++-- docs/agent-kv-api.md | 32 ++++- 6 files changed, 349 insertions(+), 44 deletions(-) diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index 5724d11537..101c986b51 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -344,7 +344,37 @@ def _salvage(job_id, org_id: str, body) -> bool: idempotency that rule protected is preserved by the attach instead: its guard requires a blank `result_ref`, so a duplicate callback attaches nothing and `_attach_or_discard` deletes the file this attempt wrote. + + **The eligibility check runs BEFORE the write, not only after it.** + `AgentKVJob.ATTACH_GUARD` is the one definition of what may be + attached, and `can_attach_result` applies it as a read first. Without + that, every redelivered callback for a job that already has its result + -- and every callback that arrives after the caller DELETEd the job -- + wrote a fresh result file to object storage, had the guard refuse it, + and then depended on `delete_result_file` to take it back out. That + delete is best-effort by construction: it logs and returns, and the + ref it would need to retry from was never persisted anywhere, so a + single failed delete left a customer's extracted output sitting in the + bucket with nothing pointing at it -- after a DELETE, or past the + retention window. Those two cases are the ordinary ones under + at-least-once delivery, and they no longer touch storage at all. + + The read is advisory, not a replacement for the guard: a cancel or a + competing callback can still land between it and the UPDATE, and the + UPDATE is what decides. What is left after this is the genuine race, + and it is the same one `_complete` has always had -- see + `storage.delete_job_files`, which reaps a job's directory so an orphan + from either path is collected when the job is deleted or expires. """ + if not AgentKVJob.can_attach_result(job_id, org_id): + logger.info( + "agent-kv finalize for cancelled job %s (org=%s) has nothing to " + "salvage -- the job already carries a result or its files have " + "been purged; no result file was written", + job_id, + org_id, + ) + return False result_ref = write_result(org_id, str(job_id), body.get("result") or {}) return _attach_or_discard(job_id, org_id, result_ref, body.get("usage_summary")) diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index d0ec1fe2c9..583d0d1f82 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -255,6 +255,39 @@ def mark_files_purged(cls, job_id, organization_id): row.files_purged = True return row + #: Every condition a late result must still satisfy to be grafted onto a + #: cancelled job. Declared once and used twice: by the authoritative + #: filtered UPDATE in :meth:`attach_result_to_cancelled`, and by + #: :meth:`can_attach_result`, which ``FinalizeView._salvage`` calls BEFORE + #: it writes anything to object storage. + #: + #: One definition because the two must not drift. A pre-flight looser than + #: the guard writes files the guard then refuses -- the orphan path. One + #: tighter silently drops results the guard would have kept, which is the + #: bug this whole salvage exists to fix. + ATTACH_GUARD = { + "status": JobStatus.CANCELLED, + "result_ref": "", + "files_purged": False, + } + + @classmethod + def can_attach_result(cls, job_id, organization_id) -> bool: + """Whether :meth:`attach_result_to_cancelled` could succeed right now. + + Advisory, never authoritative: the row can change between this read + and the guarded UPDATE, which is why the UPDATE keeps its own copy of + every clause. What this buys is not correctness but restraint -- a + callback redelivered after the result is already attached, or arriving + for a job the caller has DELETEd, is refused before a result file is + written for it. Those are the ordinary cases under at-least-once + delivery, and each one used to write a file to object storage and then + rely on a best-effort delete to take it back out again. + """ + return cls.objects.filter( + id=job_id, organization_id=organization_id, **cls.ATTACH_GUARD + ).exists() + @classmethod def attach_result_to_cancelled( cls, job_id, organization_id, *, result_ref, usage_summary=None @@ -324,11 +357,7 @@ def attach_result_to_cancelled( return False return bool( cls.objects.filter( - id=job_id, - organization_id=organization_id, - status=JobStatus.CANCELLED, - result_ref="", - files_purged=False, + id=job_id, organization_id=organization_id, **cls.ATTACH_GUARD ).update(**fields) ) diff --git a/backend/agent_kv/storage.py b/backend/agent_kv/storage.py index b19adce545..b2486f18f0 100644 --- a/backend/agent_kv/storage.py +++ b/backend/agent_kv/storage.py @@ -103,9 +103,62 @@ def delete_job_files(job) -> list[str]: ) else: cleared.append(field) + if len(cleared) == 2: + # Both named files are confirmed gone, so anything STILL under this + # job's directory is an orphan -- a result file written by a finalize + # that lost the attach guard and whose best-effort delete failed. It + # has no ref anywhere, so this is the only pass that can ever see it. + _reap_job_dir(job) return cleared +def _reap_job_dir(job) -> None: + """Remove whatever is left under ``{prefix}/{org_id}/{job_id}/``. + + Both of a job's named files live in a directory of its own, so once + ``input_ref`` and ``result_ref`` are confirmed deleted the directory is + expected to be empty -- and on flat object storage a prefix with no + objects left does not exist at all, which is why the common case costs one + ``exists`` and stops. Anything the check does find is by definition + unreferenced: a result file some finalize wrote before the attach guard + refused it, whose ``delete_result_file`` then failed. Nothing records that + ref, so neither TTL cleanup nor DELETE could otherwise reach it and the + object would stay in the bucket permanently. + + Best-effort and last: it runs only after the named deletes have already + succeeded, so a failure here cannot change which refs the caller blanks, + and a job whose own file delete failed keeps its ref for retry exactly as + before. + + ``rm_exact``, not ``rm``: ``rm`` hands its path to fsspec, which expands + it as a glob on gcsfs/s3fs. The segments here are a UUID and an org id, + which contain no glob metacharacters today -- but this is the house idiom + for "delete this exact tree" and it costs nothing to not depend on that. + + **What this does not reach.** Candidacy still comes from the ref columns: + ``run_ttl_cleanup`` selects rows with a non-blank ``input_ref`` or + ``result_ref``, so a row that has already been fully cleaned (or DELETEd) + is never visited again, and an orphan created AFTER that point is not + collected here. ``FinalizeView._salvage``'s pre-flight is what keeps that + case from arising in the ordinary redelivery-after-DELETE path; closing it + for the genuine race needs a durable marker that outlives the refs, which + is a separate piece of design. + """ + job_dir = _base(str(job.organization_id), str(job.id)) + try: + if not _fs().exists(job_dir): + return + _fs().rm_exact(job_dir) + except Exception: + logger.warning( + "agent-kv cleanup: could not reap leftovers under %s", + job_dir, + exc_info=True, + ) + else: + logger.info("agent-kv cleanup: reaped unreferenced leftovers under %s", job_dir) + + def delete_result_file(result_ref: str) -> None: """Best-effort delete of a result file that was just written but never got a ref persisted onto the job row. @@ -113,10 +166,18 @@ def delete_result_file(result_ref: str) -> None: ``FinalizeView`` writes the result *before* attempting the terminal- state guard (spec §5.4); if the guard loses the race -- a concurrent cancel or a duplicate finalize won instead -- the file this call just - wrote is orphaned: nothing anywhere points at it, so it would otherwise - sit in object storage forever, past even TTL cleanup (which only acts on - a job's *stored* ``result_ref``). Tolerant of the file being missing - already, mirroring ``delete_input``/``delete_job_files``. + wrote is orphaned: nothing anywhere points at it, so TTL cleanup cannot + see it either -- that pass acts on a job's *stored* ``result_ref``. + Tolerant of the file being missing already, mirroring + ``delete_input``/``delete_job_files``. + + When this call FAILS the object stays, and there is no ref to retry from. + ``delete_job_files`` reaps the job's whole directory once both named files + are gone, which collects such an orphan the next time the job is deleted + or expires -- provided the row is still a TTL candidate, i.e. still + carries a ref. ``FinalizeView._salvage`` refuses up front rather than + writing a file it knows will be refused, so the paths that reach this + cleanup at all are now races rather than ordinary redeliveries. """ if not result_ref: return diff --git a/backend/agent_kv/tests/test_cancelled_result_salvage.py b/backend/agent_kv/tests/test_cancelled_result_salvage.py index d2cac9fd43..c22f184f6f 100644 --- a/backend/agent_kv/tests/test_cancelled_result_salvage.py +++ b/backend/agent_kv/tests/test_cancelled_result_salvage.py @@ -488,12 +488,14 @@ def _finalize(self, job, body): from agent_kv import internal_views as iv request = APIRequestFactory().post("/x", body, format="json") - with mock.patch.object(iv.AgentKVConcurrencyLimiter, "release"): - with mock.patch.object(iv, "write_result", return_value=_RESULT): - with mock.patch.object(iv, "delete_input", return_value=True): - with mock.patch.object(iv, "delete_result_file") as m_del: - resp = iv.FinalizeView.as_view()(request, job_id=job.id) - return resp, m_del + with ( + mock.patch.object(iv.AgentKVConcurrencyLimiter, "release"), + mock.patch.object(iv, "write_result", return_value=_RESULT) as m_write, + mock.patch.object(iv, "delete_input", return_value=True), + mock.patch.object(iv, "delete_result_file") as m_del, + ): + resp = iv.FinalizeView.as_view()(request, job_id=job.id) + return resp, m_del, m_write def _body(self, **extra): body = { @@ -508,7 +510,7 @@ def _body(self, **extra): def test_a_success_callback_for_a_cancelled_job_stores_its_result(self): job = self._job(status=JobStatus.CANCELLED) - resp, m_del = self._finalize(job, self._body()) + resp, m_del, m_write = self._finalize(job, self._body()) job.refresh_from_db() assert resp.status_code == 200 @@ -531,28 +533,45 @@ def test_the_cancelled_job_keeps_its_staged_input(self): assert job.input_ref == _INPUT - def test_a_duplicate_callback_deletes_its_own_write(self): + def test_a_duplicate_callback_writes_NOTHING_to_storage(self): + """Redelivery is the ordinary case, so it must not touch the bucket. + + It used to write a fresh result file, have the guard refuse it on the + blank-`result_ref` clause, and then rely on `delete_result_file` to + take it back out -- a best-effort delete that logs and returns, with + no ref persisted anywhere to retry from. One failed delete left the + customer's extracted output in the bucket, unreferenced and invisible + to TTL cleanup. `can_attach_result` now reads the same guard first, so + there is no file to clean up and nothing to get wrong (Greptile P1). + """ job = self._job(status=JobStatus.CANCELLED) self._finalize(job, self._body()) - resp, m_del = self._finalize(job, self._body()) + resp, m_del, m_write = self._finalize(job, self._body()) job.refresh_from_db() assert resp.data["result_attached"] is False - m_del.assert_called_once_with(_RESULT) + assert not m_write.called, "a doomed attach must not write a result file" + assert not m_del.called, "nothing was written, so nothing needs deleting" assert job.result_ref == _RESULT, "the first write must stand" - def test_a_callback_for_a_purged_job_deletes_its_own_write(self): - """The invariant three attempts broke: a purged job serves nothing, and - a late callback must not leave its file behind either. + def test_a_callback_for_a_purged_job_writes_NOTHING_to_storage(self): + """The invariant three attempts broke, now enforced before the write. + + A purged job serves nothing, and a late callback must not leave its + file behind either -- which previously depended on a delete that is + allowed to fail. After DELETE this is the path that could put customer + data back into the bucket with no row referencing it, which is the + retention failure rather than a disk-usage one. """ job = self._job(status=JobStatus.CANCELLED, files_purged=True) - resp, m_del = self._finalize(job, self._body()) + resp, m_del, m_write = self._finalize(job, self._body()) job.refresh_from_db() assert resp.data["result_attached"] is False - m_del.assert_called_once_with(_RESULT) + assert not m_write.called + assert not m_del.called assert job.result_ref == "" @@ -627,3 +646,74 @@ def test_a_purged_COMPLETED_job_whose_delete_failed_serves_nothing(self): with self.assertRaises(JobNotFound): self._get_result(job) + + +class TestThePreflightAgreesWithTheGuard(_Rows): + """One definition of "may this result be attached", read twice. + + `attach_result_to_cancelled` decides; `can_attach_result` is the cheap + read `FinalizeView._salvage` takes BEFORE writing a result file, so an + attempt that is already doomed never touches object storage. Both apply + `AgentKVJob.ATTACH_GUARD`, and these tests pin that they cannot disagree. + + Why it matters that they cannot drift: a pre-flight LOOSER than the guard + writes files the guard then refuses, which is the orphan path the + pre-flight exists to close. One TIGHTER silently drops a paid-for result + the guard would have kept -- the original defect, re-introduced from the + other side and invisible, since the refusal looks exactly like a duplicate. + """ + + def _agree(self, job) -> bool: + """Assert the two answers match, and return what they said.""" + preflight = AgentKVJob.can_attach_result(job.id, self.org.id) + attached = AgentKVJob.attach_result_to_cancelled( + job.id, self.org.id, result_ref=_RESULT + ) + + assert preflight == attached, ( + f"pre-flight said {preflight} and the guard said {attached} for " + f"status={job.status} result_ref={job.result_ref!r} " + f"files_purged={job.files_purged}" + ) + return attached + + def test_an_eligible_cancelled_job_passes_both(self): + assert self._agree(self._job(status=JobStatus.CANCELLED)) is True + + def test_a_purged_job_is_refused_by_both(self): + job = self._job(status=JobStatus.CANCELLED, files_purged=True) + + assert self._agree(job) is False + + def test_a_job_that_already_has_a_result_is_refused_by_both(self): + job = self._job(status=JobStatus.CANCELLED, result_ref=_RESULT) + + assert self._agree(job) is False + + def test_a_non_cancelled_job_is_refused_by_both(self): + for status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.RUNNING): + with self.subTest(status=status): + assert self._agree(self._job(status=status)) is False + + def test_another_org_is_refused_by_both(self): + job = self._job(status=JobStatus.CANCELLED) + other = Organization.objects.create( + display_name="Other", organization_id="org_other", name="other" + ) + + assert AgentKVJob.can_attach_result(job.id, other.id) is False + assert ( + AgentKVJob.attach_result_to_cancelled(job.id, other.id, result_ref=_RESULT) + is False + ) + + def test_the_guard_is_one_dict_and_covers_every_clause(self): + """Guards the guard. Every test above would still pass if a clause were + dropped from `ATTACH_GUARD` *and* from both readers -- they would agree + on the wrong answer. This pins the clauses themselves. + """ + assert AgentKVJob.ATTACH_GUARD == { + "status": JobStatus.CANCELLED, + "result_ref": "", + "files_purged": False, + } diff --git a/backend/agent_kv/tests/test_storage.py b/backend/agent_kv/tests/test_storage.py index 257a179dea..bbf613c721 100644 --- a/backend/agent_kv/tests/test_storage.py +++ b/backend/agent_kv/tests/test_storage.py @@ -1,5 +1,6 @@ import os import re +import uuid from unittest import mock import django @@ -16,6 +17,17 @@ from agent_kv import storage # noqa: E402 from agent_kv.models import AgentKVJob # noqa: E402 +#: A job id of the shape the real paths carry, so the reconstructed job dir in +#: the reap tests is the real thing rather than "None". +_JOB_ID = uuid.UUID("3f2504e0-4f89-41d3-9a0c-0305e82c3301") + + +def _upload(name: str, data: bytes): + up = mock.Mock() + up.name = name + up.chunks.return_value = [data] + return up + @mock.patch.object(storage, "FileSystem") def test_stage_input_path_and_write(m_fs): @@ -84,17 +96,81 @@ def test_read_result_returns_parsed_json(m_fs): fh.json_load.assert_called_once_with(path="unstract/agent_kv/org1/job1/result.json") +def _job(**kwargs): + """An unsaved job that still has the identity the job-dir reap needs.""" + kwargs.setdefault("input_ref", "org/o/agent_kv/j/input.pdf") + kwargs.setdefault("result_ref", "org/o/agent_kv/j/result.json") + return AgentKVJob(id=_JOB_ID, organization_id=7, **kwargs) + + @mock.patch.object(storage, "FileSystem") def test_delete_job_files_removes_both_refs(m_fs): fh = m_fs.return_value.get_file_storage.return_value - job = AgentKVJob( - input_ref="org/o/agent_kv/j/input.pdf", - result_ref="org/o/agent_kv/j/result.json", - ) + # Flat object storage: the prefix stops existing with its last object, so + # the reap below finds nothing to do. That is the ordinary case. + fh.exists.return_value = False + job = _job() cleared = storage.delete_job_files(job) removed = {c.kwargs["path"] for c in fh.rm.call_args_list} assert removed == {job.input_ref, job.result_ref} assert cleared == ["input_ref", "result_ref"] + assert not fh.rm_exact.called + + +@mock.patch.object(storage, "FileSystem") +def test_delete_job_files_reaps_an_orphan_left_in_the_job_dir(m_fs): + """The hole a failed `delete_result_file` leaves, and the only pass that + can see it. + + A finalize that loses the attach guard writes a result file and then + deletes it best-effort. If that delete fails the object stays and NO ref + anywhere points at it -- `delete_result_file` logs and returns, and TTL + cleanup selects candidates by the refs stored on the row. Both named files + being confirmed gone is what makes anything still under the job's own + directory an orphan by definition. + """ + fh = m_fs.return_value.get_file_storage.return_value + fh.exists.return_value = True + job = _job() + + cleared = storage.delete_job_files(job) + + assert cleared == ["input_ref", "result_ref"] + fh.rm_exact.assert_called_once_with(f"unstract/agent_kv/7/{_JOB_ID}") + + +@mock.patch.object(storage, "FileSystem") +def test_the_reaped_dir_is_the_one_the_job_actually_wrote_into(m_fs): + """Guards the reap's one assumption. + + It reconstructs the directory from the job's org and id rather than from a + ref, so it still works when both refs are already blank. That is only + sound while the reconstruction matches what `stage_input`/`write_result` + actually used -- `org_id` there is `str(agent_kv_key.organization_id)`, the + same value. If the storage layout or the org identifier changes, this + fails rather than the reap silently deleting nothing (or something else). + """ + staged = storage.stage_input("7", str(_JOB_ID), _upload("x.pdf", b"a")) + written = storage.write_result("7", str(_JOB_ID), {}) + + job_dir = storage._base("7", str(_JOB_ID)) + + assert staged.rsplit("/", 1)[0] == job_dir + assert written.rsplit("/", 1)[0] == job_dir + + +@mock.patch.object(storage, "FileSystem") +def test_a_failing_reap_does_not_change_which_refs_are_reported_clear(m_fs): + """Best-effort and LAST. The reap runs only after the named deletes have + succeeded, so its failure cannot cost the caller a ref it was about to + blank -- and the orphan it could not remove is exactly as reachable as it + was before (i.e. not), which is a pre-existing hole and not a new one. + """ + fh = m_fs.return_value.get_file_storage.return_value + fh.exists.return_value = True + fh.rm_exact.side_effect = OSError("object store unreachable") + + assert storage.delete_job_files(_job()) == ["input_ref", "result_ref"] @mock.patch.object(storage, "FileSystem") @@ -115,7 +191,8 @@ def test_delete_job_files_tolerates_missing_files(m_fs): @mock.patch.object(storage, "FileSystem") def test_delete_job_files_skips_blank_refs(m_fs): fh = m_fs.return_value.get_file_storage.return_value - job = AgentKVJob(input_ref="", result_ref="") + fh.exists.return_value = False + job = _job(input_ref="", result_ref="") cleared = storage.delete_job_files(job) assert not fh.rm.called # Reported clear: nothing was ever staged, so there is nothing to retry and @@ -131,10 +208,7 @@ def test_delete_job_files_skips_blank_refs(m_fs): @mock.patch.object(storage, "FileSystem") def test_delete_job_files_omits_the_ref_whose_delete_raised(m_fs): fh = m_fs.return_value.get_file_storage.return_value - job = AgentKVJob( - input_ref="org/o/agent_kv/j/input.pdf", - result_ref="org/o/agent_kv/j/result.json", - ) + job = _job() def rm(*, path): if path == job.result_ref: @@ -147,17 +221,18 @@ def rm(*, path): # Still attempted both -- one failure must not abort the other's cleanup. assert fh.rm.call_count == 2 assert cleared == ["input_ref"] + # And NO reap: `result_ref` is being kept as the retry handle, so the file + # it points at is still wanted. Reaping the directory here would delete + # the very object the retry exists for, and blank nothing, leaving the row + # to retry forever against a file that is already gone. + assert not fh.rm_exact.called @mock.patch.object(storage, "FileSystem") def test_delete_job_files_reports_nothing_clear_when_both_deletes_raise(m_fs): fh = m_fs.return_value.get_file_storage.return_value fh.rm.side_effect = OSError("object store unreachable") - job = AgentKVJob( - input_ref="org/o/agent_kv/j/input.pdf", - result_ref="org/o/agent_kv/j/result.json", - ) - assert storage.delete_job_files(job) == [] + assert storage.delete_job_files(_job()) == [] # delete_input carries the same confirmed-clear contract as delete_job_files. diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 18cda16486..b918977447 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -670,12 +670,32 @@ attach it to the cancelled row instead served as described in [§5](#5-result--get-agent-kvjob_idresult). A success callback for an already-terminal job is therefore the one case where -finalize writes a result for a row it read as terminal. It is `CANCELLED`-only -and guarded in SQL on a blank `result_ref`, so a duplicate callback still -attaches nothing and deletes its own write — the idempotency the short-circuit -protected is unchanged. A **failure** callback for a cancelled job salvages -nothing (there is no result), and a `COMPLETED` or `FAILED` row is never given a -second look. +finalize writes a result for a row it read as terminal. It is `CANCELLED`-only, +and the conditions are declared once as `AgentKVJob.ATTACH_GUARD` — status +`CANCELLED`, blank `result_ref`, `files_purged` false — then applied twice: as +the filtered `UPDATE` that decides (`attach_result_to_cancelled`), and as a +read `_salvage` takes *first* (`can_attach_result`). The idempotency the +short-circuit protected is unchanged: a duplicate callback attaches nothing. + +**The pre-flight read is why a doomed attempt no longer touches storage.** +Writing first and cleaning up afterwards made every redelivered callback — and +every callback arriving after a `DELETE` — write a result file, have the guard +refuse it, and then depend on `delete_result_file`, which is best-effort by +construction: it logs and returns, and no ref is persisted anywhere to retry +from. A single failed delete therefore left extracted customer output in the +bucket with nothing pointing at it, after a purge or past the retention window. +Those cases now read the guard before writing anything. + +The read is advisory, not authoritative — a cancel or a competing callback can +still land between it and the `UPDATE`, which is what decides. For that genuine +race, `delete_job_files` reaps the job's own directory once both named files +are confirmed gone, so an unreferenced leftover is collected when the job is +deleted or expires. What that still cannot reach is an orphan created *after* +the row left the TTL candidate set (candidacy comes from the ref columns); +closing that needs a durable marker that outlives the refs. + +A **failure** callback for a cancelled job salvages nothing (there is no +result), and a `COMPLETED` or `FAILED` row is never given a second look. What does *not* change: `status` stays `cancelled`, `completed_at` is untouched, and `mark_terminal` remains the only way a job reaches a terminal state. The From 85c5dc609f17bb8ebc469f9e5683db4b6886a9d2 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 10:45:01 +0530 Subject: [PATCH 31/54] UN-4232 [MISC] Clear the three new SonarCloud S5778 issues on this PR All three are an exception-assertion block wrapping more than one call that could raise, so a failure in the setup call reads as the behaviour under test. * `test_compile.py` built the schema inside the `pytest.raises` block, twice, to assert the message and then the suggestion. The second block repeated the first call verbatim -- `match=` and the captured value are the same raise, so one block with `as caught` says it once. * `test_agent_kv_callbacks.py` had `with patch(...)` INSIDE `pytest.raises`, so a patch-target typo would have satisfied the assertion. The patch belongs outside; the call under test is the only thing in the block. No behaviour change. 71 agent-kv-schema and 29 ide_callback tests pass. Co-Authored-By: Claude Opus 5 --- unstract/agent-kv-schema/tests/test_compile.py | 8 ++++---- workers/ide_callback/tests/test_agent_kv_callbacks.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/unstract/agent-kv-schema/tests/test_compile.py b/unstract/agent-kv-schema/tests/test_compile.py index 26c54139ac..c043250d19 100644 --- a/unstract/agent-kv-schema/tests/test_compile.py +++ b/unstract/agent-kv-schema/tests/test_compile.py @@ -313,14 +313,14 @@ def test_a_case_variant_of_an_ARGUMENT_taking_format_is_refused(fmt, intended): reported `{"valid": true}`, and the enum was never enforced -- identical to the `"Number"` failure, through the one gap the fix for it did not close. """ - with pytest.raises(SchemaError, match="Did you mean"): - compile_schema(_one_key(fmt)) + schema = _one_key(fmt) + + with pytest.raises(SchemaError, match="Did you mean") as caught: + compile_schema(schema) # And the suggestion has to be the usable spelling, argument included -- # `"enum"` alone would be rejected by the no-values check one line later, # sending the author in a circle. - with pytest.raises(SchemaError) as caught: - compile_schema(_one_key(fmt)) assert intended in str(caught.value) diff --git a/workers/ide_callback/tests/test_agent_kv_callbacks.py b/workers/ide_callback/tests/test_agent_kv_callbacks.py index b6d338f96c..64de0e83a2 100644 --- a/workers/ide_callback/tests/test_agent_kv_callbacks.py +++ b/workers/ide_callback/tests/test_agent_kv_callbacks.py @@ -411,8 +411,8 @@ def test_agent_kv_error_matches_its_siblings_failure_posture( {"success": True, "data": {"output": {}}, "error": None}, cb_kwargs ) - with pytest.raises(RuntimeError): - with patch(_PATCH_ASYNC_RESULT, return_value=MagicMock(result="boom")): + with patch(_PATCH_ASYNC_RESULT, return_value=MagicMock(result="boom")): + with pytest.raises(RuntimeError): agent_kv_error.run("failed-task-posture", cb_kwargs) # And neither may fire a webhook off a finalize that did not happen -- From 1f1a65ee8dbaeff0df2c530306e2da316cb7a4d6 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 11:26:47 +0530 Subject: [PATCH 32/54] UN-4232 [FIX] Round-3 review: sweep race, deep-JSON 500, e2e timeout, wrong queue in the checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from Greptile on #2317. **P1 — the sweep could fail a live job.** Phase 1 picks long-PENDING, never-dispatched rows with one query and terminalizes them with another. A dispatch landing between the two turns the row into a live DISPATCHED job, and `mark_terminal` only checked "not terminal", so the sweep failed it and released its slot while the executor kept running and spending. Its finalize could not recover the output either: the row reads FAILED by then, and only a CANCELLED row is given a second look. `mark_terminal` now takes `require=`, extra clauses applied inside the same guarded UPDATE, and the sweep passes its own selection predicate -- `_NEVER_DISPATCHED`, declared once and used by both statements so they cannot drift. The age cutoff is deliberately not in it: `created_at` never changes. The 20 existing sweep tests could not have caught this. They mock `AgentKVJob.objects` and assert filter kwargs, and a mock replays whatever predicate is written -- a SELECT and an UPDATE that disagree agree perfectly there. The new tests are DB-backed and drive the real race by dispatching the row from inside a patched `mark_terminal` before delegating to the real one. **P2 — deeply nested `extractors` returned 500.** `RecursionError` is a RuntimeError, so the `except (ValueError, TypeError)` around `json.loads` never caught it. Now a validation error. The byte cap is no defence: 100k nested brackets is 200 KB against a 256 KiB cap. The first cut of the test used `sys.getrecursionlimit() * 2` and passed against the UNFIXED code, because CPython's C scanner swallows 2000 levels -- so the test now asserts that its own payload really does trip the decoder. **P2 — the sync-wait e2e test could time out on itself.** `submit_raw` fixed the HTTP read timeout at 60s while the scenario asks the backend to sync-wait 120s, so a job finishing in between failed with a client `ReadTimeout` that reads as a product bug. The client timeout now derives from the requested server wait plus a margin, so the server is always the side that gives up first and the test sees the documented fallback. **P2 — the deploy checklist named a queue this release cannot use.** It told operators to add `celery_executor_agentic_kv`. This deployment routes `table` only, to `celery_executor_agentic_table`, which every fleet already drains; the chart asserts the kv queue's absence on purpose (AKV-Q5b). It also pointed at `CELERY_QUEUES_EXECUTOR` when Celery was removed in UN-4078 and the knob is `WORKER_PG_QUEUE_CONSUMER_QUEUE`. Mutation-checked: dropping `require=` fails the two race tests and the pinned call; removing the RecursionError clause fails exactly its test. 361 agent_kv tests pass. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_serializers.py | 14 ++ backend/agent_kv/maintenance.py | 25 +++- backend/agent_kv/models.py | 21 ++- .../agent_kv/tests/test_submit_serializer.py | 32 ++++ backend/agent_kv/tests/test_sweeps.py | 138 +++++++++++++++++- docs/agent-kv-api.md | 42 ++++-- tests/e2e/agent_kv/conftest.py | 33 ++++- 7 files changed, 281 insertions(+), 24 deletions(-) diff --git a/backend/agent_kv/execution_serializers.py b/backend/agent_kv/execution_serializers.py index 01064c3c65..5c2d2d0d53 100644 --- a/backend/agent_kv/execution_serializers.py +++ b/backend/agent_kv/execution_serializers.py @@ -406,6 +406,20 @@ def validate_extractors(self, raw): entries = json.loads(raw) except (ValueError, TypeError) as e: raise serializers.ValidationError(f"extractors is not valid JSON: {e}") + except RecursionError: + # NOT a ValueError, so the clause above does not catch it: it is a + # RuntimeError subclass, and an uncaught one here is a 500 on a + # request the caller controls entirely. `[` repeated a few thousand + # times exhausts the decoder's recursion limit while occupying a + # tiny fraction of AGENT_KV_MAX_SCHEMA_BYTES (256 KiB), so the byte + # cap above is no defence at all -- the cheapest possible input + # reaches it (Greptile P2). + # + # Reported as a validation error rather than re-raised, because it + # IS one: the payload is malformed for this endpoint's purposes, + # and the caller can act on the message. Depth is not named as a + # number because the limit is the interpreter's, not ours. + raise serializers.ValidationError("extractors is nested too deeply to parse") if not isinstance(entries, list) or not entries: raise serializers.ValidationError("extractors must be a non-empty JSON array") if len(entries) > 1: diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index fbe087a010..392f25dac8 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -40,6 +40,25 @@ # the implementation starving each side in turn when one ordering tried to do # both. 20% leaves 400 slots for the normal case, where nothing is retrying. _TTL_RETRY_RESERVE = 100 +#: What "never dispatched" means, declared once because it is applied TWICE: +#: as the sweep's candidate query and again inside the guarded UPDATE that +#: terminalizes each candidate (`mark_terminal(require=...)`). +#: +#: Selecting and writing are two statements, and a delayed dispatch landing +#: between them turns a long-PENDING row into a live DISPATCHED one. Failing +#: it then would kill a job whose executor is still running and spending, and +#: release its concurrency slot -- and the executor's finalize could not +#: recover it either, because the row reads FAILED by then and only a +#: CANCELLED row is given a second look (`FinalizeView._salvage`). Re-stating +#: the predicate in the write makes the sweep lose that race instead of the +#: job (Greptile P1). +#: +#: The age cutoff is deliberately NOT in here: `created_at` never changes, so +#: re-checking it would be noise. Only the two columns a dispatch mutates are. +_NEVER_DISPATCHED = { + "status": JobStatus.PENDING, + "dispatched_at__isnull": True, +} _NEVER_DISPATCHED_ERROR = "Job was never dispatched" _STUCK_JOB_ERROR = "Job timed out" @@ -95,9 +114,8 @@ def run_sweep() -> dict: seconds=settings.AGENT_KV_SWEEP_GRACE_SECONDS ) never_dispatched_candidates = AgentKVJob.objects.filter( - status=JobStatus.PENDING, created_at__lt=never_dispatched_cutoff, - dispatched_at__isnull=True, + **_NEVER_DISPATCHED, ).order_by("created_at")[:_MAINTENANCE_BATCH_LIMIT] swept = 0 @@ -113,6 +131,9 @@ def run_sweep() -> dict: org_id, JobStatus.FAILED, error=_NEVER_DISPATCHED_ERROR, + # Re-checked in the UPDATE, not just in the SELECT above. See + # `_NEVER_DISPATCHED`. + require=_NEVER_DISPATCHED, ) if won: swept += 1 diff --git a/backend/agent_kv/models.py b/backend/agent_kv/models.py index 583d0d1f82..6586f91318 100644 --- a/backend/agent_kv/models.py +++ b/backend/agent_kv/models.py @@ -371,12 +371,24 @@ def mark_terminal( error="", result_ref="", usage_summary=None, + require=None, ) -> bool: """The ONLY way to reach a terminal state (spec §5.4 write guard). Guarded UPDATE: at-least-once callbacks, cancel, and the sweep can all race; whoever lands first wins and everyone else no-ops. + ``require`` adds caller-supplied filter kwargs to that same UPDATE, for + a caller whose decision rests on MORE than "the row is not terminal". + The sweep is the case: it picks candidates with one query and + terminalizes them with another, and between the two a delayed dispatch + can turn a long-PENDING row into a live DISPATCHED one. Without the + extra clauses this would fail a running job and release its slot while + the executor carries on spending -- and its finalize could not save the + result, because the row is FAILED by then and only a CANCELLED row gets + a second look. Re-stating the selection predicate in the write is what + closes that window; it costs nothing when nothing raced. + The `.exclude(status__in=TERMINAL)` below guards the ROW, not the argument -- so without the check that opens this method, `mark_terminal(..., JobStatus.RUNNING)` would stamp @@ -398,9 +410,8 @@ def mark_terminal( fields["result_ref"] = result_ref if usage_summary is not None: fields["usage_summary"] = usage_summary - updated = ( - cls.objects.filter(id=job_id, organization_id=organization_id) - .exclude(status__in=list(cls.TERMINAL)) - .update(**fields) - ) + rows = cls.objects.filter(id=job_id, organization_id=organization_id) + if require: + rows = rows.filter(**require) + updated = rows.exclude(status__in=list(cls.TERMINAL)).update(**fields) return updated == 1 diff --git a/backend/agent_kv/tests/test_submit_serializer.py b/backend/agent_kv/tests/test_submit_serializer.py index a94d77dcfb..473b667f4c 100644 --- a/backend/agent_kv/tests/test_submit_serializer.py +++ b/backend/agent_kv/tests/test_submit_serializer.py @@ -23,6 +23,7 @@ if not apps.ready: django.setup() +from django.conf import settings # noqa: E402 from django.core.files.uploadedfile import SimpleUploadedFile # noqa: E402 from rest_framework import serializers # noqa: E402 @@ -244,6 +245,37 @@ def test_extractors_not_json_rejected(): assert "extractors" in s.errors +#: Nesting depth that makes CPython's JSON scanner give up. NOT +#: `sys.getrecursionlimit()`: the C scanner keeps its own accounting and +#: swallows 2000 levels without complaint -- a first cut of the test below +#: used that and passed against the UNFIXED code, which is the whole failure +#: mode these tests exist to catch. 100k levels is 200 KB, comfortably inside +#: the 256 KiB `AGENT_KV_MAX_SCHEMA_BYTES` cap, and the test asserts both of +#: those facts rather than trusting this comment. +_JSON_OVERFLOW_DEPTH = 100_000 + + +def test_deeply_nested_extractors_is_a_400_not_a_500(): + """`RecursionError` is a RuntimeError, so the ValueError/TypeError clause + around `json.loads` never caught it and the request 500'd on an input the + caller controls entirely. Reported by Greptile on PR #2317. + """ + nested = "[" * _JSON_OVERFLOW_DEPTH + "]" * _JSON_OVERFLOW_DEPTH + + # The payload has to stay INSIDE the byte cap, or this proves only that + # the cap works -- which it already did. + assert len(nested.encode()) < settings.AGENT_KV_MAX_SCHEMA_BYTES + # And it has to actually trip the decoder, or the test passes for the + # wrong reason on a future interpreter that parses it happily. + with pytest.raises(RecursionError): + json.loads(nested) + + s = SubmitSerializer(data=_data(extractors=nested)) + + assert not s.is_valid() + assert "extractors" in s.errors + + def test_timeout_bounds(): s = SubmitSerializer(data=_data(timeout=301)) assert not s.is_valid() diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py index dace97106d..e5fb833324 100644 --- a/backend/agent_kv/tests/test_sweeps.py +++ b/backend/agent_kv/tests/test_sweeps.py @@ -36,11 +36,14 @@ if not apps.ready: django.setup() +from account_v2.models import Organization # noqa: E402 from django.conf import settings # noqa: E402 from django.db.models import Q # noqa: E402 from django.db.models.functions import Coalesce # noqa: E402 +from django.test import TestCase # noqa: E402 from django.utils import timezone # noqa: E402 from rest_framework.test import APIRequestFactory # noqa: E402 +from utils.user_context import UserContext # noqa: E402 from agent_kv import internal_views as iv # noqa: E402 from agent_kv import maintenance # noqa: E402 @@ -138,7 +141,16 @@ def test_sweep_terminalizes_each_candidate_as_failed_never_dispatched( assert resp.status_code == 200 m_mark_terminal.assert_called_once_with( - job.id, "org1", JobStatus.FAILED, error="Job was never dispatched" + job.id, + "org1", + JobStatus.FAILED, + error="Job was never dispatched", + # The selection predicate, restated in the guarded UPDATE so a + # dispatch landing between the two statements wins instead of being + # overwritten. See TestTheSweepLosesTheRaceRatherThanTheJob, which + # exercises the race itself against a real database -- a mock cannot, + # since it replays whatever predicate is written. + require=maintenance._NEVER_DISPATCHED, ) @@ -863,3 +875,127 @@ def test_phase_three_takes_the_newest_eligible_rows_first(): maintenance.run_sweep() phase3.order_by.assert_called_once_with("-completed_at") + + +# --------------------------------------------------------------------------- +# The select-then-write race, against a real database. +# --------------------------------------------------------------------------- +# +# Everything above mocks `AgentKVJob.objects` and asserts the filter kwargs the +# queries are built with. That style cannot see this class of defect at all: a +# mock replays whatever predicate is written, so a SELECT and an UPDATE that +# disagree agree perfectly in a mock. The bug below -- the sweep failing a job +# that was dispatched between the two statements -- was found by review, not by +# the 20 tests above it, which is the honest reason this section exists and is +# DB-backed. + + +class TestTheSweepLosesTheRaceRatherThanTheJob(TestCase): + """Phase 1 picks long-PENDING rows, then terminalizes them in a second + statement. A dispatch landing in between must not be overwritten. + + What it would cost: the executor is running and spending, the row says + FAILED and its concurrency slot has been handed to someone else, and the + finalize that eventually arrives cannot rescue the output -- only a + CANCELLED row gets a second look, so the result is written and deleted. + """ + + def setUp(self): + self.org = Organization.objects.create( + display_name="Sweep", organization_id="org_sweep", name="sweep" + ) + UserContext.set_organization_identifier(None) + self.stale = timezone.now() - timedelta( + seconds=settings.AGENT_KV_SWEEP_GRACE_SECONDS + 600 + ) + + def _pending_job(self): + job = AgentKVJob( + organization_id=self.org.id, + status=JobStatus.PENDING, + extractor="table", + ) + job.save() + # `created_at` is auto_now_add, so it has to be backdated after the + # insert for the row to be a sweep candidate at all. + AgentKVJob.objects.filter(id=job.id).update(created_at=self.stale) + return job + + def _sweep_with_a_dispatch_landing_mid_flight(self, job): + """Run the real sweep, dispatching `job` between SELECT and UPDATE. + + Patching `mark_terminal` to mutate the row and then delegate to the + real classmethod reproduces the window exactly: the candidate query has + already run and returned a snapshot, and the guarded UPDATE is about to + run against a row that no longer matches it. + """ + original = AgentKVJob.mark_terminal.__func__ + + def dispatch_then_terminalize(job_id, org_id, new_status, **kwargs): + AgentKVJob.objects.filter(id=job_id).update( + status=JobStatus.DISPATCHED, dispatched_at=timezone.now() + ) + return original(AgentKVJob, job_id, org_id, new_status, **kwargs) + + with mock.patch.object( + AgentKVJob, "mark_terminal", side_effect=dispatch_then_terminalize + ): + with mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release"): + return maintenance.run_sweep() + + def test_a_job_dispatched_mid_sweep_is_left_alone(self): + job = self._pending_job() + + result = self._sweep_with_a_dispatch_landing_mid_flight(job) + job.refresh_from_db() + + assert job.status == JobStatus.DISPATCHED, ( + "the sweep overwrote a job that was dispatched after it was " + "selected -- the executor is still running and spending" + ) + assert not job.completed_at + assert result["swept"] == 0, "a job it did not terminalize must not be counted" + + def test_its_concurrency_slot_is_not_released(self): + """The slot belongs to the live run now. Releasing it lets another + submit in over the org's limit while this one is still holding real + executor capacity -- and nothing ever reclaims the double-booking. + """ + self._pending_job() + original = AgentKVJob.mark_terminal.__func__ + + def dispatch_then_terminalize(job_id, org_id, new_status, **kwargs): + AgentKVJob.objects.filter(id=job_id).update( + status=JobStatus.DISPATCHED, dispatched_at=timezone.now() + ) + return original(AgentKVJob, job_id, org_id, new_status, **kwargs) + + with mock.patch.object( + AgentKVJob, "mark_terminal", side_effect=dispatch_then_terminalize + ): + with mock.patch.object( + maintenance.AgentKVConcurrencyLimiter, "release" + ) as m_release: + maintenance.run_sweep() + + assert not m_release.called + + def test_an_undisturbed_candidate_is_still_swept(self): + """The guard must not cost the sweep its actual job.""" + job = self._pending_job() + + with mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release"): + result = maintenance.run_sweep() + job.refresh_from_db() + + assert job.status == JobStatus.FAILED + assert result["swept"] == 1 + + def test_the_predicate_is_declared_once_for_both_statements(self): + """The candidate query and the guarded UPDATE must not drift apart -- + that drift IS the bug. Both read `_NEVER_DISPATCHED`. + """ + assert maintenance._NEVER_DISPATCHED == { + "status": JobStatus.PENDING, + "dispatched_at__isnull": True, + } diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index b918977447..b32ea704a2 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -978,21 +978,33 @@ beyond `docker compose up`: The marker the probe keys off is the mere presence of `backend/plugins/agent_kv/__init__.py` — a cloud image ships it (via the plugin-copy step), an OSS-only build does not. -2. **Executor worker fleet queue**: the executor dispatch derives its Celery queue name - from the executor name as `celery_executor_{executor_name}` — for Agent-KV that's - **`celery_executor_agentic_kv`** (`unstract/sdk1/src/unstract/sdk1/execution/dispatcher.py`). - `workers/run-worker.sh`'s executor role default queue list - (`celery_executor_legacy,celery_executor_agentic,celery_executor_agentic_table`) - does **not** include it — a self-hosted/OSS-only deployment running the cloud - `agentic_kv` executor plugin must add `celery_executor_agentic_kv` to that worker's - consumed queues (`CELERY_QUEUES_EXECUTOR` on the Docker path, or the equivalent - `run-worker.sh` queue map entry) or dispatched jobs will never be picked up. - **The cloud Helm chart already wires this**: both `workerExecutorV2.args` - (`--queues=...,celery_executor_agentic_kv`) and - `workerPgExecutor.env.WORKER_PG_QUEUE_CONSUMER_QUEUE` include it, and both fleets - carry `additionalConfigs: [..., agentKv]` (`charts/unstract-platform/values.yaml`, - cloud repo) so the executor pods get the `AGENT_KV_*` LLM/LLMWhisperer env alongside - the queue wiring. +2. **Executor worker fleet queue — nothing to add for this release.** The dispatcher + derives the queue name from the EXECUTOR name as `celery_executor_{executor_name}` + (`unstract/sdk1/src/unstract/sdk1/execution/dispatcher.py`), and this deployment + routes `table` only, to the **`agentic_table`** executor + (`EXTRACTOR_ROUTES`, `backend/agent_kv/constants.py`). So the queue this API + actually uses is **`celery_executor_agentic_table`**, which every fleet already + drains: it is in `workers/run-worker.sh`'s executor default list, and in + `workerPgExecutor.env.WORKER_PG_QUEUE_CONSUMER_QUEUE` in the cloud chart. + + > **Do not add `celery_executor_agentic_kv`.** Earlier revisions of this checklist + > told operators to, and it is wrong in this release: `kv` is not routable + > (§1), nothing produces to that queue, and nothing consumes it. The chart asserts + > its absence deliberately — `AKV-Q5b` in + > `charts/unstract-platform/unittests/agent_kv_wiring_test.yaml` fails the build if + > the name appears anywhere in the chart, because a queue with a consumer and no + > producer wastes a fleet lane, and one with a producer and no consumer accepts + > work and drains nothing with no error anywhere. It becomes the right queue only + > when `kv` is re-enabled in `EXTRACTOR_ROUTES`, and + > `backend/agent_kv/tests/test_queue_wiring_is_derived.py` derives the expectation + > from that constant so the two cannot disagree. + + Note the knob name too: since Celery was removed (UN-4078) the deployed fleet is + the PostgreSQL queue consumer, so the setting that matters is + **`WORKER_PG_QUEUE_CONSUMER_QUEUE`**, not `CELERY_QUEUES_EXECUTOR`. Both fleets + also carry `additionalConfigs: [..., agentKv]` + (`charts/unstract-platform/values.yaml`, cloud repo) so the executor pods get the + `AGENT_KV_*` LLM/LLMWhisperer env alongside the queue wiring. 3. **Callback queue**: the `ide_callback` worker must consume `agent_kv_callback` alongside its own `ide_callback` queue, or jobs dispatch but never finalize. This is already the hardcoded default in both `workers/run-worker-docker.sh` and diff --git a/tests/e2e/agent_kv/conftest.py b/tests/e2e/agent_kv/conftest.py index 392ccba914..ccd742bf12 100644 --- a/tests/e2e/agent_kv/conftest.py +++ b/tests/e2e/agent_kv/conftest.py @@ -245,10 +245,41 @@ def submit_raw( headers=auth.headers, files={"file": (filename, file_bytes, "application/octet-stream")}, data=data, - timeout=60, + timeout=_http_timeout(data.get("timeout")), ) +#: Client read timeout for a submit that does NOT ask the backend to sync-wait. +_HTTP_TIMEOUT = 60 + +#: Added on top of a requested sync-wait so the CLIENT always outlives the +#: SERVER's own deadline. +_HTTP_TIMEOUT_MARGIN = 30 + + +def _http_timeout(server_wait: object) -> int: + """Seconds to let the HTTP request run for, given the sync-wait asked for. + + `timeout` in the submit body is the SERVER's wait: the backend holds the + request open that long for a terminal state before falling back to the 202 + handshake. The client's own read timeout has to outlast it, or a job that + finishes inside the window the test asked for still fails the test -- with + a `requests.ReadTimeout` from the harness, which reads as a product bug and + is not one. `test_sync_wait_submit_returns_result_inline` asked for 120 s + against a fixed 60 s client timeout, so any run slower than a warm cache + was a coin flip (Greptile P2). + + The margin covers the handshake and the response body on top of the + backend's own deadline, so the SERVER is always the side that gives up + first and the test sees the documented fallback rather than an exception. + """ + try: + wait = int(server_wait) # type: ignore[arg-type] + except (TypeError, ValueError): + return _HTTP_TIMEOUT + return max(_HTTP_TIMEOUT, wait + _HTTP_TIMEOUT_MARGIN) + + def submit( auth: AgentKVAuth, file_bytes: bytes, From 043664544d5fd5daa71dc165758b3033efc5ba7e Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 11:41:01 +0530 Subject: [PATCH 33/54] UN-4232 [FIX] Don't hand a TextChoices member to subTest; xdist cannot ship it `test_a_non_cancelled_job_is_refused_by_both` passed locally and died in CI with `execnet.gateway_base.DumpError: can't serialize `. Nothing was wrong with the assertion. pytest's unittest integration calls `addSubTest` for every subtest, passing included, and under xdist that report -- subtest parameters and all -- is serialized back to the controller through execnet, which dispatches on exact type. A `TextChoices` member is a `str` subclass but not a `str`, so it has no handler and the whole test is reported as a failure whose traceback is entirely inside the reporting machinery. Reproduced in isolation rather than guessed at: `dumps(S.COMPLETED)` for a `class S(str, Enum)` raises `DumpError: can't serialize `, while `dumps(str(S.COMPLETED))` is fine. Invisible locally because pytest-xdist is not in the backend's own test dependencies -- the rig adds it -- so nothing is ever serialized here. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/tests/test_cancelled_result_salvage.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/agent_kv/tests/test_cancelled_result_salvage.py b/backend/agent_kv/tests/test_cancelled_result_salvage.py index c22f184f6f..a28491f1dc 100644 --- a/backend/agent_kv/tests/test_cancelled_result_salvage.py +++ b/backend/agent_kv/tests/test_cancelled_result_salvage.py @@ -692,7 +692,14 @@ def test_a_job_that_already_has_a_result_is_refused_by_both(self): def test_a_non_cancelled_job_is_refused_by_both(self): for status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.RUNNING): - with self.subTest(status=status): + # `str(status)`, not the enum: pytest-xdist ships a failing + # subTest's parameters back to the controller through execnet, + # which cannot serialize a `TextChoices` member -- the test then + # dies with `DumpError: can't serialize ` + # instead of reporting whatever it actually found. Invisible + # locally (no xdist, nothing is serialized) and only ever seen in + # CI, which is the worst place to debug it. + with self.subTest(status=str(status)): assert self._agree(self._job(status=status)) is False def test_another_org_is_refused_by_both(self): From e7bdccf0fc9018212b53729b5c74e378be83db88 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 13:30:32 +0530 Subject: [PATCH 34/54] UN-4232 [FEAT] Settings page for Agent-KV API keys, and stop listing them in plaintext The keys the public API authenticates with could only be created by POSTing to the management endpoint with an admin session. There was no page, no nav entry and nothing in either frontend -- so the honest answer to "how does a user get a key?" was "open devtools". The backend half of the pattern shipped and the browser half did not. `ApiKeyManager` already backs Platform API keys and Global API deployment keys, so the page is a thin wrapper: Settings -> Agent-KV API Keys, admin-only (matching `IsOrganizationAdmin` on the viewset), with create, copy, rotate, enable/disable and delete. An Agent-KV key carries no permission tier and no deployment scope, so it passes none of the extra-field hooks. **The list endpoint had to stop returning real keys first.** One serializer answered every action, so the table that page renders would have printed every key in the organization in full -- the whole set on screen, and into any screenshot or support ticket, to read one of them. `platform_api` settled this already, and the viewset now mirrors it: `list` masks to `****-` and adds `created_by_email`; `retrieve`, `create` and `rotate` carry the full value, each being a deliberate request for ONE key the caller has a reason to hold. Create also gains the sanitised, required `name`/`description` and the friendly per-org duplicate-name check its platform twin has, instead of surfacing the model's unique constraint as a 500. No external breakage: these endpoints are new in this PR and had no consumer. Tests. Backend: each action gets the serializer matching its exposure, the list never carries a usable key, detail does, and a key whose creator was deleted still names one. Frontend: the page reads `/agent-kv/keys/` and not `platform-api` -- a copy of `PlatformApiKeys` that forgot to change `resourcePath` renders an identical, plausible screen while deleting PLATFORM keys, and the URL is the only thing that distinguishes them. Mutation-checked; the title assertion alone does not catch it. 366 agent_kv tests, 625 frontend tests, biome clean on the new files. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/serializers.py | 95 ++++++++++++++- backend/agent_kv/tests/test_key_management.py | 112 ++++++++++++++++-- backend/agent_kv/views.py | 70 +++++++---- docs/agent-kv-api.md | 27 ++++- .../navigations/side-nav-bar/SideNavBar.jsx | 13 ++ .../settings/agent-kv-keys/AgentKvKeys.jsx | 39 ++++++ .../agent-kv-keys/AgentKvKeys.test.jsx | 62 ++++++++++ frontend/src/pages/AgentKvKeysPage.jsx | 7 ++ frontend/src/routes/useMainAppRoutes.jsx | 8 ++ 9 files changed, 398 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/settings/agent-kv-keys/AgentKvKeys.jsx create mode 100644 frontend/src/components/settings/agent-kv-keys/AgentKvKeys.test.jsx create mode 100644 frontend/src/pages/AgentKvKeysPage.jsx diff --git a/backend/agent_kv/serializers.py b/backend/agent_kv/serializers.py index 081aeabb6b..11fe0ad295 100644 --- a/backend/agent_kv/serializers.py +++ b/backend/agent_kv/serializers.py @@ -1,15 +1,102 @@ +"""Key-management serializers, mirroring ``platform_api.serializers``. + +Four serializers rather than two, and the split is the point: **the list +endpoint must not hand back plaintext keys.** It backs a table that shows every +key in the organization at once, so returning the real value there puts the +whole set on screen (and into any screenshot, support ticket or browser cache) +to read one of them. ``platform_api`` already settled this -- list masks, and +the full value comes from create, rotate and an explicit per-key retrieve, each +of which is a deliberate request for one key. + +The earlier single ``AgentKVKeySerializer`` returned the real key everywhere. +That was survivable only while nothing called the endpoint; it stopped being so +the moment the keys page shipped. +""" + from rest_framework import serializers +from utils.input_sanitizer import validate_safe_text +from utils.user_context import UserContext from agent_kv.models import AgentKVKey -class AgentKVKeySerializer(serializers.ModelSerializer): +def _masked(key) -> str: + """Last four characters only -- enough to tell two keys apart, useless to a + reader. Same shape as ``PlatformApiKeyListSerializer.get_key``. + """ + return f"****-{str(key)[-4:]}" + + +class AgentKVKeyListSerializer(serializers.ModelSerializer): + key = serializers.SerializerMethodField() + created_by_email = serializers.SerializerMethodField() + + class Meta: + model = AgentKVKey + fields = [ + "id", + "name", + "description", + "key", + "is_active", + "created_at", + "modified_at", + "created_by_email", + ] + + def get_key(self, obj): + return _masked(obj.key) + + def get_created_by_email(self, obj): + # The FK is `on_delete=SET_NULL`, so a key outlives the admin who made + # it. "Deleted user" rather than blank: an empty cell reads as a bug. + return obj.created_by.email if obj.created_by else "Deleted user" + + +class AgentKVKeyDetailSerializer(serializers.ModelSerializer): + """Create / rotate / retrieve -- the responses that show the full key. + + Each is a request for ONE key the caller already has a reason to hold: + they just minted it, just replaced it, or clicked copy on a row. + """ + class Meta: model = AgentKVKey - fields = ["id", "name", "description", "key", "is_active", "created_at"] + fields = ["id", "name", "key", "is_active"] -class AgentKVKeyWriteSerializer(serializers.ModelSerializer): +class AgentKVKeyCreateSerializer(serializers.ModelSerializer): + description = serializers.CharField(required=True, max_length=512) + class Meta: model = AgentKVKey - fields = ["name", "description", "is_active"] + fields = ["name", "description"] + + def validate_name(self, value): + value = validate_safe_text(value) + # The model has a unique constraint on (name, organization), which + # would surface as an IntegrityError-shaped 500 or an opaque + # "unique_agent_kv_key_name_per_org" string. Checked here so the admin + # is told what to change. + organization = UserContext.get_organization() + if AgentKVKey.objects.filter(name=value, organization=organization).exists(): + raise serializers.ValidationError( + "A key with this name already exists in your organization." + ) + return value + + def validate_description(self, value): + return validate_safe_text(value) + + +class AgentKVKeyUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = AgentKVKey + fields = ["description", "is_active"] + extra_kwargs = { + "description": {"required": False}, + "is_active": {"required": False}, + } + + def validate_description(self, value): + return validate_safe_text(value) diff --git a/backend/agent_kv/tests/test_key_management.py b/backend/agent_kv/tests/test_key_management.py index f092f53bdd..869ff61450 100644 --- a/backend/agent_kv/tests/test_key_management.py +++ b/backend/agent_kv/tests/test_key_management.py @@ -12,12 +12,17 @@ from utils.filters.organization_filter import OrganizationFilterBackend # noqa: E402 from agent_kv.models import AgentKVKey # noqa: E402 -from agent_kv.serializers import AgentKVKeyWriteSerializer # noqa: E402 +from agent_kv.serializers import ( # noqa: E402 + AgentKVKeyCreateSerializer, + AgentKVKeyDetailSerializer, + AgentKVKeyListSerializer, + AgentKVKeyUpdateSerializer, +) from agent_kv.views import AgentKVKeyViewSet # noqa: E402 -def test_write_serializer_rejects_blank_name(): - s = AgentKVKeyWriteSerializer(data={"name": "", "description": "x"}) +def test_create_serializer_rejects_blank_name(): + s = AgentKVKeyCreateSerializer(data={"name": "", "description": "x"}) assert not s.is_valid() assert "name" in s.errors @@ -63,7 +68,7 @@ def test_create_returns_id_and_key(): """POST .../keys/ must echo the read serializer, not the write one. A caller who just created a key has no other way to learn its ``id`` or - the raw ``key`` value -- ``AgentKVKeyWriteSerializer`` (used to validate + the raw ``key`` value -- ``AgentKVKeyCreateSerializer`` (used to validate the request) carries neither. ``serializer.save`` is stubbed rather than hitting the real ORM: this file has no DB fixture, and ``ModelSerializer .save()``'s default create path resolves ``AgentKVKey._default_manager`` @@ -84,12 +89,105 @@ def _fake_save(self, **kwargs): view.action = "create" view.format_kwarg = None view.kwargs = {} - view.request = mock.Mock(data={"name": "k"}, user=mock.Mock()) + view.request = mock.Mock( + data={"name": "k", "description": "d"}, user=mock.Mock() + ) - with mock.patch.object(AgentKVKeyWriteSerializer, "save", _fake_save): - resp = view.create(view.request) + # `validate_name` checks the per-org name uniqueness the model constrains, + # which is a real query; both halves of it are stubbed because this file + # has no database. Mocking `.objects` is safe HERE and nowhere else in + # this test — see the note above about `save()` resolving + # `_default_manager` instead — because `save` is stubbed too. + with mock.patch.object(AgentKVKeyCreateSerializer, "save", _fake_save): + with mock.patch("agent_kv.serializers.UserContext.get_organization"): + with mock.patch.object(AgentKVKey, "objects") as m_objects: + m_objects.filter.return_value.exists.return_value = False + resp = view.create(view.request) assert resp.status_code == 201, resp.data # DRF serializes UUID fields as strings. assert resp.data["id"] == str(key_obj.id), resp.data assert resp.data["key"] == str(key_obj.key), resp.data + + +# --------------------------------------------------------------------------- +# What each response is allowed to show. +# --------------------------------------------------------------------------- +# +# The keys page lists every key in the organization in one table. Returning the +# real value there would put the whole set on screen to read one of them, so +# list MASKS and only the three single-key responses (create, rotate, an +# explicit retrieve behind the copy button) carry the plaintext. This mirrors +# `platform_api`, which settled the same question first; the single serializer +# this replaced returned the real key everywhere, which was survivable only +# while nothing called the endpoint. + + +def _key(**kwargs): + kwargs.setdefault("key", uuid.UUID("3f2504e0-4f89-41d3-9a0c-0305e82c3301")) + kwargs.setdefault("name", "k") + kwargs.setdefault("description", "d") + return AgentKVKey(id=uuid.uuid4(), is_active=True, **kwargs) + + +def test_the_list_never_carries_a_usable_key(): + key_obj = _key() + + data = AgentKVKeyListSerializer(key_obj).data + + assert data["key"] == "****-3301" + assert str(key_obj.key) not in data["key"] + + +def test_the_detail_response_carries_the_real_key(): + """Create, rotate and copy are each a request for ONE key the caller + already has a reason to hold. A masked value here would leave the copy + button with nothing to copy. + """ + key_obj = _key() + + data = AgentKVKeyDetailSerializer(key_obj).data + + assert data["key"] == str(key_obj.key) + + +def test_a_key_whose_creator_was_deleted_still_names_a_creator(): + """`created_by` is `on_delete=SET_NULL`, so a key outlives the admin who + made it. An empty cell in the table reads as a bug rather than as history. + """ + assert AgentKVKeyListSerializer(_key()).data["created_by_email"] == "Deleted user" + + +def test_each_action_gets_the_serializer_that_matches_its_exposure(): + """The routing itself, since every assertion above tests a serializer in + isolation and would still pass if the viewset picked the wrong one. + """ + view = AgentKVKeyViewSet() + expected = { + "list": AgentKVKeyListSerializer, + "retrieve": AgentKVKeyDetailSerializer, + "rotate": AgentKVKeyDetailSerializer, + "create": AgentKVKeyCreateSerializer, + "partial_update": AgentKVKeyUpdateSerializer, + } + + for action, serializer in expected.items(): + view.action = action + assert view.get_serializer_class() is serializer, action + + +def test_rotate_returns_the_new_key_in_full(): + """The point of rotating is to receive the replacement; a masked response + would make the action useless. + """ + key_obj = _key() + old = key_obj.key + with mock.patch.object(AgentKVKey, "save"): + view = AgentKVKeyViewSet() + view.get_object = lambda: key_obj + view.format_kwarg = None + view.request = mock.Mock() + resp = view.rotate(view.request, pk=str(key_obj.id)) + + assert resp.data["key"] == str(key_obj.key) + assert resp.data["key"] != str(old) diff --git a/backend/agent_kv/views.py b/backend/agent_kv/views.py index 466a4c7eee..62df724c92 100644 --- a/backend/agent_kv/views.py +++ b/backend/agent_kv/views.py @@ -4,14 +4,28 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.serializers import BaseSerializer from agent_kv.models import AgentKVKey from agent_kv.permissions import IsOrganizationAdmin -from agent_kv.serializers import AgentKVKeySerializer, AgentKVKeyWriteSerializer +from agent_kv.serializers import ( + AgentKVKeyCreateSerializer, + AgentKVKeyDetailSerializer, + AgentKVKeyListSerializer, + AgentKVKeyUpdateSerializer, +) class AgentKVKeyViewSet(viewsets.ModelViewSet): + """Key management for the Agent-KV public API. + + Shaped on ``platform_api.views.PlatformApiKeyViewSet`` because the + ``ApiKeyManager`` screen in the frontend is shared between them: the table + reads a masked ``key`` plus ``created_by_email`` from the list, and the + copy/create/rotate actions read the real value from a single-key response. + A viewset that returned one serializer for everything would either leak + every key into the table or leave the copy button with nothing to copy. + """ + permission_classes = [IsAuthenticated, IsOrganizationAdmin] def get_queryset(self): @@ -21,35 +35,51 @@ def get_queryset(self): return AgentKVKey.objects.all() def get_serializer_class(self): - if self.action in ("create", "update", "partial_update"): - return AgentKVKeyWriteSerializer - return AgentKVKeySerializer + if self.action == "create": + return AgentKVKeyCreateSerializer + if self.action == "partial_update": + return AgentKVKeyUpdateSerializer + if self.action in ("retrieve", "rotate"): + return AgentKVKeyDetailSerializer + return AgentKVKeyListSerializer - def perform_create(self, serializer: BaseSerializer) -> None: + def perform_create(self, serializer): serializer.save(created_by=self.request.user) def create(self, request, *args, **kwargs): - """Respond with the read serializer, not the write one (DRF's default). - - ``AgentKVKeyWriteSerializer`` (used to validate the request, per - ``get_serializer_class``) only carries ``name``/``description``/ - ``is_active`` -- the standard ``CreateModelMixin.create`` would echo - that same serializer back, meaning the caller who just created a key - would never see ``id`` or the raw ``key`` value itself. Both are - server-generated and this is the only response that will ever carry - the plaintext key (list/retrieve return it too today, but rotate is - the only other place a caller can *see* a fresh one) -- mirrors - ``GlobalApiDeploymentKeyViewSet.create``. + """Respond with the DETAIL serializer, not the write one (DRF's default). + + ``AgentKVKeyCreateSerializer`` only carries ``name``/``description``, + so the standard ``CreateModelMixin.create`` would echo that back and + the admin who just created a key would never see ``id`` or the key + itself. Both are server-generated, and create is one of the three + places the plaintext value is ever returned — mirrors + ``PlatformApiKeyViewSet.create``. """ serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) - response_serializer = AgentKVKeySerializer(serializer.instance) - return Response(response_serializer.data, status=status.HTTP_201_CREATED) + return Response( + AgentKVKeyDetailSerializer(serializer.instance).data, + status=status.HTTP_201_CREATED, + ) + + def partial_update(self, request, *args, **kwargs): + """Answer with the LIST shape so the table can patch its row in place. + + The update serializer carries only the writable fields; echoing it + would leave the row without ``key``, ``created_at`` or + ``created_by_email`` until the next refetch. + """ + instance = self.get_object() + serializer = AgentKVKeyUpdateSerializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + updated = serializer.save() + return Response(AgentKVKeyListSerializer(updated).data) @action(detail=True, methods=["post"]) def rotate(self, request, pk=None): key_obj = self.get_object() key_obj.key = uuid.uuid4() key_obj.save(update_fields=["key", "modified_at"]) - return Response(AgentKVKeySerializer(key_obj).data) + return Response(AgentKVKeyDetailSerializer(key_obj).data) diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index b32ea704a2..a4dcd29aea 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -50,10 +50,29 @@ as-is rather than adding a new exception class) for both a missing/malformed Key management (`backend/agent_kv/views.py`, `AgentKVKeyViewSet`) is a standard DRF `ModelViewSet` scoped by the global `OrganizationFilterBackend` and gated by -`IsOrganizationAdmin` (`backend/agent_kv/permissions.py`). Fields returned: -`id, name, description, key, is_active, created_at`; write-side fields are -`name, description, is_active` only (`key` and `id` are server-generated). Routes -(`backend/agent_kv/urls.py`, mounted at `agent-kv/` under +`IsOrganizationAdmin` (`backend/agent_kv/permissions.py`). It is shaped on +`platform_api.views.PlatformApiKeyViewSet`, including **where the plaintext key is +and is not returned**: + +| action | serializer | `key` | +|---|---|---| +| `list` | `AgentKVKeyListSerializer` | masked, `****-`; adds `created_by_email` | +| `retrieve` / `create` / `rotate` | `AgentKVKeyDetailSerializer` | **full value** | +| `create` (request) | `AgentKVKeyCreateSerializer` | `name`, `description`, both required and sanitised | +| `partial_update` (request) | `AgentKVKeyUpdateSerializer` | `description`, `is_active` | + +The split exists because `list` backs a table of every key in the organization: +returning real values there puts the whole set on screen to read one of them. The +three single-key responses each answer a deliberate request for one key. + +**Browser UI**: *Settings → Agent-KV API Keys* +(`frontend/src/components/settings/agent-kv-keys/AgentKvKeys.jsx`, routed at +`settings/agent-kv-keys` behind `RequireAdmin`). It is a thin wrapper over the shared +`ApiKeyManager` screen that also backs Platform API keys and Global API deployment +keys — create, copy, rotate, enable/disable and delete. Admin-only, matching +`IsOrganizationAdmin` on the viewset. + +Routes (`backend/agent_kv/urls.py`, mounted at `agent-kv/` under `backend/backend/urls_v2.py`): ``` diff --git a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx index cfa789c94d..cc4925fdfe 100644 --- a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx +++ b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx @@ -112,6 +112,11 @@ const getSettingsMenuItems = (orgName, isAdmin) => [ label: "Global API Deployment Keys", path: `/${orgName}/settings/global-api-deployment-keys`, }, + { + key: "agentKvKeys", + label: "Agent-KV API Keys", + path: `/${orgName}/settings/agent-kv-keys`, + }, ] : []), { @@ -152,6 +157,12 @@ const getActiveSettingsKey = () => { if (currentPath.includes("/settings/platform-api-keys")) { return "platformApiKeys"; } + // Before the "/settings/platform" test below, which is a prefix of nothing + // here but sits in a chain where the first match wins — the neighbouring + // key routes are ordered on the same basis. + if (currentPath.includes("/settings/agent-kv-keys")) { + return "agentKvKeys"; + } if (currentPath.includes("/settings/platform")) { return "platform"; } @@ -483,6 +494,8 @@ const SideNavBar = ({ collapsed, setCollapsed }) => { `/${orgName}/settings/platform-api-keys` || globalThis.location.pathname === `/${orgName}/settings/global-api-deployment-keys` || + globalThis.location.pathname === + `/${orgName}/settings/agent-kv-keys` || globalThis.location.pathname === `/${orgName}/settings/triad` || globalThis.location.pathname === `/${orgName}/settings/review` || globalThis.location.pathname === `/${orgName}/users` || diff --git a/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.jsx b/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.jsx new file mode 100644 index 0000000000..9aec232204 --- /dev/null +++ b/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.jsx @@ -0,0 +1,39 @@ +import { Typography } from "@/components/ui/shims/antd-typography"; + +import { ApiKeyManager } from "../api-key-manager/ApiKeyManager.jsx"; + +/** + * Key management for the Agent-KV extraction API. + * + * A thin wrapper over `ApiKeyManager`, like `PlatformApiKeys` and + * `GlobalApiDeploymentKeys`: the shared screen owns the table and the + * create/edit/rotate/delete/copy flows, and the only thing that differs here + * is the resource path and the fact that there is nothing extra to collect — + * an Agent-KV key carries no permission tier and no deployment scope, so + * neither `extraColumns` nor the field hooks are passed. + * + * `resourcePath` resolves to `/api/v1/unstract/{orgId}/agent-kv/keys/` + * (`backend/agent_kv/urls.py`, mounted in `urls_v2.py`). That is the + * tenant-scoped, session-authenticated management surface — NOT the public + * `/agent-kv/` execution prefix the resulting key is then used against, which + * is whitelisted out of the session middleware and authenticates with the key + * itself. + */ +function AgentKvKeys() { + return ( + ( + + Send this key as Authorization: Bearer <key> when + calling the Agent-KV extraction API. It is shown in full once, here + and on rotate; the list only ever shows the last four characters. + + )} + /> + ); +} + +export { AgentKvKeys }; diff --git a/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.test.jsx b/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.test.jsx new file mode 100644 index 0000000000..cad3b84c70 --- /dev/null +++ b/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.test.jsx @@ -0,0 +1,62 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Which keys this screen manages. + * + * `AgentKvKeys` is a thin wrapper over the shared `ApiKeyManager`, and every + * request it makes is built from one prop: `resourcePath`. A copy of + * `PlatformApiKeys` that forgot to change that prop renders an identical, + * entirely plausible screen — titled "Agent-KV API Keys", reached from the + * Agent-KV nav entry — while creating, rotating and DELETING platform API + * keys. Nothing visible distinguishes the two, so the URL is the assertion. + */ +const axiosPrivate = vi.fn(() => Promise.resolve({ data: [] })); + +vi.mock("../../../hooks/useAxiosPrivate", () => ({ + useAxiosPrivate: () => axiosPrivate, +})); +vi.mock("../../../hooks/useCopyToClipboard", () => ({ + useCopyToClipboard: () => vi.fn(), +})); +vi.mock("../../../hooks/useExceptionHandler.jsx", () => ({ + useExceptionHandler: () => (_err, fallback) => ({ content: fallback }), +})); +vi.mock("../../../store/alert-store", () => ({ + useAlertStore: () => ({ setAlertDetails: vi.fn() }), +})); +vi.mock("../../../store/session-store", () => ({ + useSessionStore: () => ({ + sessionDetails: { orgId: "org_test", orgName: "acme", csrfToken: "t" }, + }), +})); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { ...actual, useNavigate: () => vi.fn() }; +}); + +const { AgentKvKeys } = await import("./AgentKvKeys.jsx"); + +describe("AgentKvKeys", () => { + beforeEach(() => { + axiosPrivate.mockClear(); + }); + + it("reads keys from the agent-kv resource, not the platform one", async () => { + render(); + + await waitFor(() => expect(axiosPrivate).toHaveBeenCalled()); + + const urls = axiosPrivate.mock.calls.map(([args]) => args?.url); + expect(urls).toContain("/api/v1/unstract/org_test/agent-kv/keys/"); + expect(urls.join(" ")).not.toContain("platform-api"); + }); + + it("is titled for the API the key is used against", async () => { + render(); + + await waitFor(() => + expect(screen.getByText("Agent-KV API Keys")).toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/src/pages/AgentKvKeysPage.jsx b/frontend/src/pages/AgentKvKeysPage.jsx new file mode 100644 index 0000000000..52d13fea6f --- /dev/null +++ b/frontend/src/pages/AgentKvKeysPage.jsx @@ -0,0 +1,7 @@ +import { AgentKvKeys } from "../components/settings/agent-kv-keys/AgentKvKeys.jsx"; + +function AgentKvKeysPage() { + return ; +} + +export { AgentKvKeysPage }; diff --git a/frontend/src/routes/useMainAppRoutes.jsx b/frontend/src/routes/useMainAppRoutes.jsx index b8234c44b3..22cc585b47 100644 --- a/frontend/src/routes/useMainAppRoutes.jsx +++ b/frontend/src/routes/useMainAppRoutes.jsx @@ -66,6 +66,10 @@ const OutputAnalyzerPage = lazyNamed( () => import("../pages/OutputAnalyzerPage.jsx"), "OutputAnalyzerPage", ); +const AgentKvKeysPage = lazyNamed( + () => import("../pages/AgentKvKeysPage.jsx"), + "AgentKvKeysPage", +); const PlatformApiKeysPage = lazyNamed( () => import("../pages/PlatformApiKeysPage.jsx"), "PlatformApiKeysPage", @@ -314,6 +318,10 @@ function useMainAppRoutes() { path="settings/global-api-deployment-keys" element={} /> + {/* Under RequireAdmin with its siblings: the backend gates key + management on IsOrganizationAdmin, so a non-admin reaching this + route would get a page of 403s rather than a usable screen. */} + } /> } /> {RequirePlatformAdmin && PlatformAdminPage && ( From 872f8d6a3830040c53787e2727de1a4461fa7386 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 14:30:19 +0530 Subject: [PATCH 35/54] UN-3883 [FIX] Stop the WFE index-planner fixture rendering a float as scientific notation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `(random() * n || ' days')::interval` round-trips the float through its own text form, and Postgres renders a small enough double in scientific notation — which `::interval` then rejects outright: invalid input syntax for type interval: "7.418860519248227e-05 days" `random()` produces that whenever it lands below ~1e-04, so the fixture failed roughly one run in ten thousand per row, as a hard DataError in a test about index planning. Multiplying an interval by a double never formats anything, so the whole class of failure goes away: `random() * n * INTERVAL '1 day'`. Verified against a real Postgres rather than by re-running and hoping, since the odds make a green run prove nothing. The exact value from the CI log raises with the old form and yields `00:03:12.296865` with the new one; the midpoint and the maximum still give 15 and 30 days, so the distribution is unchanged. Not this branch's code — last touched by UN-3883 (#2276) — but it has now failed CI twice on #2317 and is the only thing red there, so it is fixed here rather than left to make the PR look broken. One SQL string in one fixture; no product code is involved. Co-Authored-By: Claude Opus 5 --- .../tests/test_wfe_status_created_idx_planner.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/workflow_manager/file_execution/tests/test_wfe_status_created_idx_planner.py b/backend/workflow_manager/file_execution/tests/test_wfe_status_created_idx_planner.py index 9f4cc6f2b0..3a0ed5bc8c 100644 --- a/backend/workflow_manager/file_execution/tests/test_wfe_status_created_idx_planner.py +++ b/backend/workflow_manager/file_execution/tests/test_wfe_status_created_idx_planner.py @@ -100,8 +100,21 @@ def setUpTestData(cls) -> None: # created_at for every row in whatever database this happens to run against. with connection.cursor() as cur: cur.execute( + # `random() * n * INTERVAL '1 day'`, NOT + # `(random() * n || ' days')::interval`. The text form round-trips + # the float through its own string representation, and Postgres + # renders a small enough double in scientific notation -- which + # `::interval` then rejects outright: + # + # invalid input syntax for type interval: + # "7.418860519248227e-05 days" + # + # `random()` returns that whenever it lands below ~1e-04, so this + # failed roughly one run in ten thousand per row, as a hard DataError + # in a test about index planning. Multiplying an interval by a double + # never formats anything, so the whole class of failure is gone. "UPDATE workflow_file_execution SET created_at = %s::timestamptz" - " - (random() * %s || ' days')::interval" + " - random() * %s * INTERVAL '1 day'" " WHERE workflow_execution_id = %s", [now.isoformat(), _SPAN_DAYS, str(execution.id)], ) From 9f834253a153eb55e7ca3815c9a497c590cddbb6 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 14:47:23 +0530 Subject: [PATCH 36/54] UN-4232 [FEAT] Copy ID on the adapter card, so an API caller can name an adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent-KV API makes the caller name their own `llm` / `lite_llm` / `x2text` adapters by INSTANCE id, and that id was not in the product anywhere: the card renders the adapter NAME, and `item.id` was used as a React key and for edit/delete but never shown or copyable. The first attempt to call the API answered `Must be a valid UUID` three times over, with no way forward short of the network tab or a shell in the cluster. Added as an entry in the card's existing kebab menu rather than new chrome, so all four adapter screens (LLM, embedding, vector DB, X2Text) pick it up without a layout change. Two things the test pins, because both are silent when wrong: * It copies `item.id`, NOT `item.adapter_id`. Both sit on the row and both look like identifiers; `adapter_id` is the provider descriptor (`anthropic|90ebd4cd-…`), shared by every instance of that provider, so the wrong one copies happily and fails much later at submit as "no such adapter in this organization". * The handler is on the menu ENTRY. The dropdown opens on pointerdown and pins `pointer-events: none` on while open, so a handler on the kebab trigger never fires -- the trap Manage Users hit, where Edit looked like it did nothing. The test drives it the way a browser allows, with a pointerdown on the trigger and a click on the entry. Mutation-checked both ways: swapping to `adapter_id` fails it, and removing the entry fails it. 626 frontend tests, biome clean. Co-Authored-By: Claude Opus 5 --- docs/agent-kv-api.md | 9 ++ .../list-of-items/ListOfItems.jsx | 31 ++++++- .../list-of-items/ListOfItems.test.jsx | 83 +++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/tool-settings/list-of-items/ListOfItems.test.jsx diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index a4dcd29aea..2e3efbbb24 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -65,6 +65,15 @@ The split exists because `list` backs a table of every key in the organization: returning real values there puts the whole set on screen to read one of them. The three single-key responses each answer a deliberate request for one key. +**Getting the adapter ids**: each adapter card under *Settings → LLMs / +X2Text* has **Copy ID** in its kebab menu +(`frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx`). That +copies the instance UUID this API expects — not `adapter_id`, which is the +provider descriptor (`anthropic|90ebd4cd-…`) shared by every instance of that +provider and rejected here as "no such adapter in this organization". The same +ids are listable at +`GET {PATH_PREFIX}/unstract/{org}/adapter/?adapter_type=LLM`. + **Browser UI**: *Settings → Agent-KV API Keys* (`frontend/src/components/settings/agent-kv-keys/AgentKvKeys.jsx`, routed at `settings/agent-kv-keys` behind `RequireAdmin`). It is a thin wrapper over the shared diff --git a/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx b/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx index 14cbfc20fa..3421bb84fb 100644 --- a/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx +++ b/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx @@ -1,9 +1,10 @@ -import { EllipsisVertical, Pencil, Trash2 } from "lucide-react"; +import { Copy, EllipsisVertical, Pencil, Trash2 } from "lucide-react"; import PropTypes from "prop-types"; import { Image } from "@/components/ui/shims/antd-leaves"; import { Dropdown } from "@/components/ui/shims/antd-overlays"; import { Card } from "@/components/ui/shims/antd-structure"; +import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard"; import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal"; import { EmptyState } from "../../widgets/empty-state/EmptyState"; import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader"; @@ -16,6 +17,8 @@ function ListOfItems({ handleDelete, handleClick, }) { + const copyToClipboard = useCopyToClipboard(); + if (isLoading) { return ; } @@ -52,6 +55,32 @@ function ListOfItems({ icon: , onClick: () => setEditItemId(item?.id), }, + { + // The adapter's instance UUID. It was reachable only + // through the network tab: the card renders the NAME, + // and `id` was used as a React key and for + // edit/delete but never shown. Callers of the + // Agent-KV API have to name their own `llm` / + // `lite_llm` / `x2text` adapters by this id, so + // "which UUID is 'sonnet 4.6'?" had no answer in the + // product. + // + // `item.id`, NOT `item.adapter_id` -- the latter is + // the provider descriptor (`anthropic|90ebd4cd-...`), + // shared by every instance of that provider, and + // passing it where an instance id belongs fails with + // "no such adapter in this organization". + // + // On the menu ENTRY rather than the kebab trigger: + // the dropdown opens on pointerdown and pins + // `pointer-events: none` on while open, so a + // handler on the trigger itself never fires (the same + // trap Manage Users hit). + label: "Copy ID", + key: "copy-id", + icon: , + onClick: () => copyToClipboard(item?.id, "Adapter ID"), + }, { label: ( while open, so a handler on the + * kebab trigger never fires — the trap Manage Users hit, where Edit looked + * like it did nothing. + */ +const copyToClipboard = vi.fn(); + +vi.mock("../../../hooks/useCopyToClipboard", () => ({ + useCopyToClipboard: () => copyToClipboard, +})); + +const { ListOfItems } = await import("./ListOfItems.jsx"); + +const ROWS = [ + { + id: "ff3f12e5-f119-44cb-9ecb-f35f16f87cbf", + adapter_id: "anthropic|90ebd4cd-2f19-4cef-a884-9eeb6ac0f203", + adapter_name: "sonnet 4.6", + adapter_type: "LLM", + }, +]; + +function openTheCardMenu(container) { + // The trigger is the kebab SVG itself, carrying `aria-haspopup="menu"`. + // Radix opens on POINTERDOWN, not click — firing only a click leaves the + // menu shut and the entry unfindable. + const trigger = container.querySelector('[aria-haspopup="menu"]'); + expect(trigger, "the card must still have a kebab menu").to.exist; + fireEvent.pointerDown( + trigger, + new window.PointerEvent("pointerdown", { bubbles: true, button: 0 }), + ); +} + +function renderList() { + return render( + , + ); +} + +describe("ListOfItems — Copy ID", () => { + beforeEach(() => { + copyToClipboard.mockClear(); + }); + + it("copies the instance id, not the provider descriptor", async () => { + const { container } = renderList(); + + openTheCardMenu(container); + const entry = await screen.findByText("Copy ID"); + fireEvent.click(entry); + + await waitFor(() => expect(copyToClipboard).toHaveBeenCalled()); + const [value] = copyToClipboard.mock.calls[0]; + expect(value).toBe(ROWS[0].id); + expect(value).not.toBe(ROWS[0].adapter_id); + }); +}); From c356946a2b2efdae5f6193c907d4c458194430ee Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 14:57:23 +0530 Subject: [PATCH 37/54] UN-4232 [DOCS] Document `detected` / `reason` on the table result The cloud engine now says whether the table was found at all (Zipstack/unstract-cloud#1828). No backend change: `result_payload` serves the stored engine output verbatim, so the two keys surface on `GET /agent-kv/{job_id}/result` untouched. Section 5 gains the distinction that matters to a caller holding an empty, already-billed result: `detected: false` means no page matched `target_table` and the run stopped before extraction, so `target_table` is what to change; `detected: true` with `row_count: 0` means the table WAS found and extraction returned nothing, so it is not. Also says to DESCRIBE the table rather than label it, since the string is fed to the agent that writes the detection prompt. Co-Authored-By: Claude Opus 5 --- docs/agent-kv-api.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 2e3efbbb24..c43b8abe7a 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -633,6 +633,8 @@ under `extractors.table`: "table_pages": [1, 2], "headers": ["unit", "rent"], "row_count": 1, + "detected": true, + "reason": "", "timing": {"document_processing": 6.2, "total": 41.7} } }, @@ -648,7 +650,25 @@ under `extractors.table`: `tables` carries one object per extracted row; `headers` is the canonical column list the engine settled on; `table_pages` are the 1-based pages the rows came from; and `row_count` is the number of rows extracted. `timing` rides inside the extractor block, -and `parse_failures` appears only when some page failed to parse. There is **no +and `parse_failures` appears only when some page failed to parse. + +**`detected` / `reason` — why an empty result is empty.** A run that finds nothing is +a *successful* run, and it bills: the detection agents have already been paid for by +the time the pipeline gives up. Two quite different outcomes used to arrive looking +identical — `tables: []`, `row_count: 0`, `success: true`: + +* `detected: false` — no page contained a table matching `target_table`, so the run + stopped **before** extraction. `reason` names the input to change, e.g. + `"no page contained a table matching target_table='Rent roll'"`. Describe the table + rather than labelling it: the string is fed to the agent that writes the detection + prompt, so `"Rent roll with unit, tenant, lease start and monthly rent"` detects far + more reliably than `"Rent roll"`. +* `detected: true` with `row_count: 0` — the table WAS found and extraction returned + no rows. `target_table` is not the thing to change here. + +Both keys are always present, including on a successful extraction (`reason` is then +`""`). The distinction was previously only inferable from which keys were *missing* +from `timing`, which is an internal structure rather than a contract. There is **no `cost_summary`** on this extractor — the table engine has no per-agent cost tracker — so `usage_summary` is the only billing figure, as it is for `kv`. From 67b85dc21820b2b4e27fa42f75f8eda2fddc74d3 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Tue, 13 Oct 2026 15:05:18 +0530 Subject: [PATCH 38/54] UN-4232 [FIX] Put Copy ID on the adapter table that is actually rendered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added it to `ListOfItems`, which **nothing imports**. `Settings → LLMs` and `Text Extractor` render `ToolSettings`, which uses `ResourceTable`; `ListOfItems` is a card component that reads exactly like the adapter list and is dead. So the button was unreachable, and the docs promised a control that did not exist on any page. Worse than the bug: the test. It mounted `ListOfItems` directly, passed, and survived two mutations -- swapping `id` for `adapter_id` and deleting the menu entry -- while proving nothing about the product. A component test cannot tell you its component is rendered, and I did not check. Caught in review (Greptile P1 on #2317). `ResourceTable` gains an opt-in `handleCopyId`, gated like `handleShare` so it appears only where a caller asks: most resources are reached by name and their id is an implementation detail; adapters are the exception, because the Agent-KV API makes the caller name their `llm` / `lite_llm` / `x2text` by instance id. Not gated on `deprecated`/`locked` like Edit and Delete -- copying an identifier changes nothing, and someone debugging a deprecated or another owner's adapter is exactly who needs to read it. `ToolSettings` passes it and owns the `item.id` vs `item.adapter_id` decision (the latter is the provider descriptor, shared across instances, which fails at submit as "no such adapter in this organization"). The test now mounts `ToolSettings` -- the routed screen -- and finds the control by its accessible name. Mutation-checked against all three ways this breaks: the screen not passing the prop (the bug above) fails it, the widget dropping the button fails it, and copying `adapter_id` fails the id assertion. `ListOfItems` is reverted to its prior state rather than carrying a feature nobody can reach. It is still dead code; removing it is its own change. 627 frontend tests, biome clean, no new warnings on the two files touched. Co-Authored-By: Claude Opus 5 --- docs/agent-kv-api.md | 7 +- .../list-of-items/ListOfItems.jsx | 31 +---- .../list-of-items/ListOfItems.test.jsx | 83 -------------- .../tool-settings/ToolSettings.jsx | 17 +++ .../tool-settings/ToolSettings.test.jsx | 107 ++++++++++++++++++ .../widgets/resource-table/ResourceTable.jsx | 28 +++++ 6 files changed, 157 insertions(+), 116 deletions(-) delete mode 100644 frontend/src/components/tool-settings/list-of-items/ListOfItems.test.jsx create mode 100644 frontend/src/components/tool-settings/tool-settings/ToolSettings.test.jsx diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index c43b8abe7a..d64135d13a 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -65,9 +65,10 @@ The split exists because `list` backs a table of every key in the organization: returning real values there puts the whole set on screen to read one of them. The three single-key responses each answer a deliberate request for one key. -**Getting the adapter ids**: each adapter card under *Settings → LLMs / -X2Text* has **Copy ID** in its kebab menu -(`frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx`). That +**Getting the adapter ids**: each adapter row under *Settings → LLMs / +X2Text* has a **Copy ID** button in its action column +(`ResourceTable`'s opt-in `handleCopyId`, wired by +`frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx`). That copies the instance UUID this API expects — not `adapter_id`, which is the provider descriptor (`anthropic|90ebd4cd-…`) shared by every instance of that provider and rejected here as "no such adapter in this organization". The same diff --git a/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx b/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx index 3421bb84fb..14cbfc20fa 100644 --- a/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx +++ b/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx @@ -1,10 +1,9 @@ -import { Copy, EllipsisVertical, Pencil, Trash2 } from "lucide-react"; +import { EllipsisVertical, Pencil, Trash2 } from "lucide-react"; import PropTypes from "prop-types"; import { Image } from "@/components/ui/shims/antd-leaves"; import { Dropdown } from "@/components/ui/shims/antd-overlays"; import { Card } from "@/components/ui/shims/antd-structure"; -import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard"; import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal"; import { EmptyState } from "../../widgets/empty-state/EmptyState"; import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader"; @@ -17,8 +16,6 @@ function ListOfItems({ handleDelete, handleClick, }) { - const copyToClipboard = useCopyToClipboard(); - if (isLoading) { return ; } @@ -55,32 +52,6 @@ function ListOfItems({ icon: , onClick: () => setEditItemId(item?.id), }, - { - // The adapter's instance UUID. It was reachable only - // through the network tab: the card renders the NAME, - // and `id` was used as a React key and for - // edit/delete but never shown. Callers of the - // Agent-KV API have to name their own `llm` / - // `lite_llm` / `x2text` adapters by this id, so - // "which UUID is 'sonnet 4.6'?" had no answer in the - // product. - // - // `item.id`, NOT `item.adapter_id` -- the latter is - // the provider descriptor (`anthropic|90ebd4cd-...`), - // shared by every instance of that provider, and - // passing it where an instance id belongs fails with - // "no such adapter in this organization". - // - // On the menu ENTRY rather than the kebab trigger: - // the dropdown opens on pointerdown and pins - // `pointer-events: none` on while open, so a - // handler on the trigger itself never fires (the same - // trap Manage Users hit). - label: "Copy ID", - key: "copy-id", - icon: , - onClick: () => copyToClipboard(item?.id, "Adapter ID"), - }, { label: ( while open, so a handler on the - * kebab trigger never fires — the trap Manage Users hit, where Edit looked - * like it did nothing. - */ -const copyToClipboard = vi.fn(); - -vi.mock("../../../hooks/useCopyToClipboard", () => ({ - useCopyToClipboard: () => copyToClipboard, -})); - -const { ListOfItems } = await import("./ListOfItems.jsx"); - -const ROWS = [ - { - id: "ff3f12e5-f119-44cb-9ecb-f35f16f87cbf", - adapter_id: "anthropic|90ebd4cd-2f19-4cef-a884-9eeb6ac0f203", - adapter_name: "sonnet 4.6", - adapter_type: "LLM", - }, -]; - -function openTheCardMenu(container) { - // The trigger is the kebab SVG itself, carrying `aria-haspopup="menu"`. - // Radix opens on POINTERDOWN, not click — firing only a click leaves the - // menu shut and the entry unfindable. - const trigger = container.querySelector('[aria-haspopup="menu"]'); - expect(trigger, "the card must still have a kebab menu").to.exist; - fireEvent.pointerDown( - trigger, - new window.PointerEvent("pointerdown", { bubbles: true, button: 0 }), - ); -} - -function renderList() { - return render( - , - ); -} - -describe("ListOfItems — Copy ID", () => { - beforeEach(() => { - copyToClipboard.mockClear(); - }); - - it("copies the instance id, not the provider descriptor", async () => { - const { container } = renderList(); - - openTheCardMenu(container); - const entry = await screen.findByText("Copy ID"); - fireEvent.click(entry); - - await waitFor(() => expect(copyToClipboard).toHaveBeenCalled()); - const [value] = copyToClipboard.mock.calls[0]; - expect(value).toBe(ROWS[0].id); - expect(value).not.toBe(ROWS[0].adapter_id); - }); -}); diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx index 9cc0beed92..bf6a76801b 100644 --- a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx +++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement"; +import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { applyPagedResponse, @@ -63,6 +64,7 @@ function ToolSettings({ type }) { const { sessionDetails } = useSessionStore(); const { setAlertDetails } = useAlertStore(); const axiosPrivate = useAxiosPrivate(); + const copyToClipboard = useCopyToClipboard(); const handleException = useExceptionHandler(); // Monotonic request token so a stale response can't overwrite a newer one. const seqRef = useRef(0); @@ -225,6 +227,20 @@ function ToolSettings({ type }) { .catch((err) => setAlertDetails(handleException(err))); }; + /** + * Copy the adapter's INSTANCE id — what the Agent-KV API means by + * `adapters: {llm, lite_llm, x2text}`. + * + * `item.id`, NOT `item.adapter_id`. Both sit on the row and both look like + * identifiers; `adapter_id` is the provider descriptor + * (`anthropic|90ebd4cd-...`), shared by every instance of that provider, so + * the wrong one copies happily and fails much later at submit as "no such + * adapter in this organization". + */ + const handleCopyId = (_event, item) => { + copyToClipboard(item?.id, "Adapter ID"); + }; + const handleEdit = (_event, item) => { if (item?.is_deprecated) { setAlertDetails({ @@ -411,6 +427,7 @@ function ToolSettings({ type }) { dateProp="created_at" handleEdit={handleEdit} handleShare={handleShare} + handleCopyId={handleCopyId} handleDelete={handleDelete} handleCoOwner={handleCoOwner} sessionDetails={sessionDetails} diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.test.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.test.jsx new file mode 100644 index 0000000000..b7a19d73ec --- /dev/null +++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.test.jsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Copying an adapter's id, driven through the screen that is actually routed. + * + * This test exists because the first attempt at the feature did not. The Copy + * ID action was added to `ListOfItems` — a card component that reads exactly + * like the adapter list and is imported by NOTHING. `Settings → LLMs` renders + * `ToolSettings`, which uses `ResourceTable`. A unit test that mounted + * `ListOfItems` directly passed, mutation-checked and all, while the button + * was unreachable in the product. Caught in review (Greptile P1 on #2317). + * + * So the assertion that matters is not "the widget calls its prop" but "the + * routed screen puts the control on the page". Mount `ToolSettings`. + */ +const copyToClipboard = vi.fn(); +const ADAPTER = { + id: "ff3f12e5-f119-44cb-9ecb-f35f16f87cbf", + adapter_id: "anthropic|90ebd4cd-2f19-4cef-a884-9eeb6ac0f203", + adapter_name: "sonnet 4.6", + adapter_type: "LLM", + description: "", + created_at: "2026-10-01T00:00:00Z", + modified_at: "2026-10-01T00:00:00Z", + created_by_email: "owner@example.com", + shared_users: [], +}; + +vi.mock("../../../hooks/useCopyToClipboard", () => ({ + useCopyToClipboard: () => copyToClipboard, +})); +vi.mock("../../../hooks/useAxiosPrivate", () => ({ + useAxiosPrivate: () => () => Promise.resolve({ data: [ADAPTER] }), +})); +vi.mock("../../../hooks/useExceptionHandler", () => ({ + useExceptionHandler: () => (_e, fallback) => ({ content: fallback }), +})); +vi.mock("../../../hooks/usePostHogEvents", () => ({ + default: () => ({ setPostHogCustomEvent: () => undefined }), +})); +// `CoOwnerModal` renders unconditionally and dereferences +// `coOwner.coOwnerData.coOwners`, so the shape matters even though the modal +// is shut. +vi.mock("../../../hooks/useCoOwnerManagement", () => ({ + useCoOwnerManagement: () => ({ + handleCoOwner: vi.fn(), + isCoOwnerModalOpen: false, + closeCoOwnerModal: vi.fn(), + coOwnerData: { coOwners: [], createdBy: null }, + coOwnerAllUsers: [], + coOwnerLoading: false, + setCoOwnerModalOpen: vi.fn(), + }), +})); +vi.mock("../../../store/alert-store", () => ({ + useAlertStore: () => ({ setAlertDetails: vi.fn() }), +})); +vi.mock("../../../store/session-store", () => ({ + useSessionStore: () => ({ + sessionDetails: { + orgId: "org_test", + orgName: "acme", + email: "owner@example.com", + }, + }), +})); +vi.mock("../../groups/groups-service.js", () => ({ + groupsService: () => ({ getGroups: () => Promise.resolve({ data: [] }) }), +})); + +const { ToolSettings } = await import("./ToolSettings.jsx"); + +describe("ToolSettings — Copy ID", () => { + beforeEach(() => { + copyToClipboard.mockClear(); + }); + + it("puts a Copy ID control on the adapter row", async () => { + render( + + + , + ); + + const button = await screen.findByLabelText("Copy Adapter ID"); + fireEvent.click(button); + + await waitFor(() => expect(copyToClipboard).toHaveBeenCalled()); + }); + + it("copies the instance id, not the provider descriptor", async () => { + render( + + + , + ); + + fireEvent.click(await screen.findByLabelText("Copy Adapter ID")); + + await waitFor(() => expect(copyToClipboard).toHaveBeenCalled()); + const [value] = copyToClipboard.mock.calls[0]; + expect(value).toBe(ADAPTER.id); + expect(value).not.toBe(ADAPTER.adapter_id); + }); +}); diff --git a/frontend/src/components/widgets/resource-table/ResourceTable.jsx b/frontend/src/components/widgets/resource-table/ResourceTable.jsx index e9aaa0c1ee..e30e8efdb7 100644 --- a/frontend/src/components/widgets/resource-table/ResourceTable.jsx +++ b/frontend/src/components/widgets/resource-table/ResourceTable.jsx @@ -4,6 +4,7 @@ import { ChevronDown, ChevronUp, CircleHelp, + Copy, Eraser, Pencil, Share2, @@ -146,6 +147,7 @@ function ResourceTable({ extraColumns = [], handleEdit, handleShare, + handleCopyId, handleDelete, handleCoOwner, onRowClick, @@ -326,6 +328,31 @@ function ResourceTable({ )} + {/* + * Opt-in, like Share: most resources are reached by name and their id + * is an implementation detail. Adapters are the exception — the + * Agent-KV API makes the caller name their `llm` / `lite_llm` / + * `x2text` adapters by instance id, and that id was in no screen at + * all, so the first API call answered `Must be a valid UUID` with no + * way forward but the network tab. + * + * Deliberately NOT gated on `deprecated` or `locked`: copying an + * identifier changes nothing, and a caller debugging a deprecated or + * someone else's adapter is exactly who needs to read its id. + */} + {handleCopyId && ( + + + + )} {/* * The confirm panel is NOT keyed by row: only one can be open at a * time, so a per-row id would just make the locator longer without @@ -503,6 +530,7 @@ ResourceTable.propTypes = { extraColumns: PropTypes.array, handleEdit: PropTypes.func, handleShare: PropTypes.func, + handleCopyId: PropTypes.func, handleDelete: PropTypes.func, handleCoOwner: PropTypes.func, onRowClick: PropTypes.func, From 4058fd8f72b0355c80b71517fa9d7cd68011d10a Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 10:28:33 +0530 Subject: [PATCH 39/54] UN-4232 [FIX] Stop deep-copying the upload; every submit over 2.5 MB was a 500 `_request_data_with_extractors_inlined` opened with `request.data.copy()`, and `QueryDict.copy()` is `__deepcopy__` -- Django's own one-line definition. Above `FILE_UPLOAD_MAX_MEMORY_SIZE` (2.5 MB by default) an upload is a `TemporaryUploadedFile` whose handle is a `BufferedRandom`, which deep copy cannot pickle: TypeError: cannot pickle 'BufferedRandom' instances So every submit over 2.5 MB died as a 500 before validation ran, on an endpoint advertising `AGENT_KV_MAX_FILE_SIZE_MB` = 50. The rent-roll fixture is 2,492 bytes, so the unit suite and every e2e run sat three orders of magnitude under the threshold and nothing saw it -- including the live runs against a real deployment. Replaced with a shallow mutable copy that carries the values by reference. `setlist` per key rather than `dict(source)`, because `tags` is a `ListField` and flattening a QueryDict keeps only the last value of a repeated field; `test_real_serializer_through_real_view_reaches_dispatch_intact_for_table` fails if that is got wrong, which is how I know it matters beyond assertion. A non-form request has no `getlist` and nothing to preserve, so it is simply shallow copied. The test took two attempts and the first one is the more useful record: it declared a 3 MB `TemporaryUploadedFile` but wrote twelve bytes into the request, and the upload handler -- which cares what arrives, not what is claimed -- kept it in memory, so it passed against the unfixed code. It now sends real bytes past the threshold and asserts the upload is disk-backed before testing anything, so a change to the threshold or the handlers fails it rather than quietly testing the in-memory path again. Mutation-checked: restoring `.copy()` fails it, and flattening to `dict(source)` fails the multi-value test. 367 agent_kv tests pass. Greptile P1 on #2317. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views.py | 31 ++++++++++- backend/agent_kv/tests/test_submit_view.py | 60 ++++++++++++++++++++++ docs/agent-kv-api.md | 7 +++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/backend/agent_kv/execution_views.py b/backend/agent_kv/execution_views.py index 908b2865c7..b72aa05d84 100644 --- a/backend/agent_kv/execution_views.py +++ b/backend/agent_kv/execution_views.py @@ -3,6 +3,7 @@ from datetime import timedelta from django.conf import settings +from django.http import QueryDict from django.utils import timezone from plugins import get_plugin from rest_framework.exceptions import ValidationError @@ -276,6 +277,34 @@ def _sync_wait_response(job, wait: float) -> Response | None: return None +def _shallow_mutable(source): + """A mutable copy of the request data that does NOT copy the uploads. + + `QueryDict.copy()` is `__deepcopy__` (Django's own one-line definition), + and an upload above `FILE_UPLOAD_MAX_MEMORY_SIZE` -- 2.5 MB by default -- + is a `TemporaryUploadedFile` whose handle is a `BufferedRandom`. Deep + copying one raises `TypeError: cannot pickle 'BufferedRandom' instances`, + so every submit over 2.5 MB died as a 500 before validation ran, on an + endpoint that advertises `AGENT_KV_MAX_FILE_SIZE_MB` = 50 (Greptile P1). + + `setlist` per key rather than `dict(source)`, because `tags` is a + `ListField`: flattening a QueryDict to a plain dict keeps only the last + value of a repeated form field, which would silently drop every tag but + one. The values -- including the upload objects -- are carried by + reference, which is the point. + + A non-form request (no `getlist`) is already a plain mutable dict of + JSON-decoded values with nothing to preserve, so it is simply shallow + copied. + """ + if not hasattr(source, "getlist"): + return dict(source) + data = QueryDict(mutable=True) + for key in source: + data.setlist(key, source.getlist(key)) + return data + + def _request_data_with_extractors_inlined(request): """A mutable copy of the request data with a file-part `extractors` read in. @@ -289,7 +318,7 @@ def _request_data_with_extractors_inlined(request): the ``str``) before being rejected at 256 KiB. One such request per worker process OOMs the pod, which makes it a cheap denial of service. """ - data = request.data.copy() + data = _shallow_mutable(request.data) part = data.get("extractors") if not hasattr(part, "read"): # not a file part (§7.1); nothing to inline return data diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py index e3c285830c..37c1728edf 100644 --- a/backend/agent_kv/tests/test_submit_view.py +++ b/backend/agent_kv/tests/test_submit_view.py @@ -12,6 +12,8 @@ from django.conf import settings # noqa: E402 from django.utils import timezone # noqa: E402 +from rest_framework.parsers import MultiPartParser # noqa: E402 +from rest_framework.request import Request # noqa: E402 from rest_framework.test import APIRequestFactory # noqa: E402 from agent_kv import execution_views as ev # noqa: E402 @@ -1144,3 +1146,61 @@ def test_a_deprecated_adapter_is_refused_at_submit( assert "deprecated" in str(resp.data), resp.data assert not m_dispatch.called assert not m_stage.called + + +# --------------------------------------------------------------------------- +# Uploads bigger than Django's in-memory threshold. +# --------------------------------------------------------------------------- + + +def test_an_upload_over_the_memory_threshold_is_not_a_500(): + """`QueryDict.copy()` is `__deepcopy__`, and a disk-backed upload cannot be + deep-copied. + + Django switches an upload to `TemporaryUploadedFile` above + `FILE_UPLOAD_MAX_MEMORY_SIZE` (2.5 MB by default). Its handle is a + `BufferedRandom`, so `copy.deepcopy` raises + `TypeError: cannot pickle 'BufferedRandom' instances` -- before any + validation runs, as a 500, on an endpoint that advertises + `AGENT_KV_MAX_FILE_SIZE_MB` = 50. + + Every test and every e2e run used a 2.5 KB fixture, a factor of a thousand + below the threshold, so nothing ever saw it (Greptile P1). + + The payload has to be REAL bytes. A first cut declared a 3 MB + `TemporaryUploadedFile` but wrote twelve bytes into the request, and the + upload handler -- which cares about what arrives, not what is claimed -- + kept it in memory. The test passed against the unfixed code. Hence the + explicit type assertion below: if the threshold, the handlers or the + factory ever change, this fails rather than quietly testing the in-memory + path again. + """ + from django.conf import settings + from django.core.files.uploadedfile import TemporaryUploadedFile + + from agent_kv.execution_views import _request_data_with_extractors_inlined + + payload = b"%PDF-1.4\n" + b"0" * (settings.FILE_UPLOAD_MAX_MEMORY_SIZE + 4096) + request = APIRequestFactory().post( + "/agent-kv/", + { + "file": SimpleUploadedFile("big.pdf", payload, "application/pdf"), + "extractors": '[{"name": "table"}]', + "tags": ["a", "b"], + }, + format="multipart", + ) + drf_request = Request(request, parsers=[MultiPartParser()]) + + assert isinstance(drf_request.data["file"], TemporaryUploadedFile), ( + "the upload must be DISK-backed for this test to exercise anything; " + "an in-memory one deep-copies fine" + ) + + data = _request_data_with_extractors_inlined(drf_request) + + # The handle must survive, not a copy of it: `stage_input` reads it later. + assert data["file"].size == len(payload) + # And the multi-valued field must still be multi-valued -- a plain + # `dict(request.data)` would keep only the last `tags` entry. + assert data.getlist("tags") == ["a", "b"] diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index d64135d13a..c1a81e8201 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -667,6 +667,13 @@ identical — `tables: []`, `row_count: 0`, `success: true`: * `detected: true` with `row_count: 0` — the table WAS found and extraction returned no rows. `target_table` is not the thing to change here. +A third shape sits between them: detection may have been **incomplete**. A page +whose presence-detection response fails to parse does not abort the run, so +`reason` then reads `detection did not complete: presence detection failed on +page(s) 2; …`, and `parse_failures` is carried out alongside it naming those +pages. Treat that as "unknown", not as "absent" — the table may well be on a page +that was never read. + Both keys are always present, including on a successful extraction (`reason` is then `""`). The distinction was previously only inferable from which keys were *missing* from `timing`, which is an internal structure rather than a contract. There is **no From 985ee80c57829ce0f33f9429dc805102c13dbd2e Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 09:57:28 +0530 Subject: [PATCH 40/54] [MISC] Remove the dead ListOfItems adapter card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing imports it. Every adapter screen — Settings → LLMs, Vector DBs, Embeddings, Text Extractor, OCR — renders `ToolSettings`, which has used `ResourceTable` since before this commit's parent. `ListOfItems` is a card component with working Edit and Delete handlers that reads exactly like the adapter list and is reachable from nowhere. That resemblance is the reason to delete it rather than leave it. On #2317 a "Copy ID" action was added to this file in good faith, with a unit test that mounted the component directly, passed, and survived two mutations. The button shipped unreachable; review caught it, the suite could not. The next person looking for "the adapter list" will find this file first too. Verified unreferenced before deleting, not just by grepping the name: * no import of `ListOfItems` or `list-of-items` anywhere under `frontend/`, in this repo or in `unstract-cloud`, whose frontend plugins overlay onto this same `src` tree; * no computed module loading anywhere in the frontend — no `import.meta.glob`, no `require.context`, no template-literal `import()` — so there is no path a literal grep would miss; * `ListOfItems.css` is imported only by the file being deleted with it. And confirmed by building: `vite build` resolves every import in the app and succeeds, which a dangling reference would not. `tsc --noEmit` clean, 625 frontend tests pass, biome clean. Co-Authored-By: Claude Opus 5 --- .../list-of-items/ListOfItems.css | 14 --- .../list-of-items/ListOfItems.jsx | 95 ------------------- 2 files changed, 109 deletions(-) delete mode 100644 frontend/src/components/tool-settings/list-of-items/ListOfItems.css delete mode 100644 frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx diff --git a/frontend/src/components/tool-settings/list-of-items/ListOfItems.css b/frontend/src/components/tool-settings/list-of-items/ListOfItems.css deleted file mode 100644 index 22f02ea3ee..0000000000 --- a/frontend/src/components/tool-settings/list-of-items/ListOfItems.css +++ /dev/null @@ -1,14 +0,0 @@ -/* Styles for ListOfItems */ - -.list-of-items-empty { - height: 100%; - display: flex; - justify-content: center; - align-items: center; -} - -.cover-img .fit-cover { - object-fit: cover; - width: 100%; - height: auto; -} diff --git a/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx b/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx deleted file mode 100644 index 14cbfc20fa..0000000000 --- a/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import { EllipsisVertical, Pencil, Trash2 } from "lucide-react"; -import PropTypes from "prop-types"; -import { Image } from "@/components/ui/shims/antd-leaves"; -import { Dropdown } from "@/components/ui/shims/antd-overlays"; -import { Card } from "@/components/ui/shims/antd-structure"; - -import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal"; -import { EmptyState } from "../../widgets/empty-state/EmptyState"; -import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader"; -import "./ListOfItems.css"; - -function ListOfItems({ - isLoading, - tableRows, - setEditItemId, - handleDelete, - handleClick, -}) { - if (isLoading) { - return ; - } - - if (!tableRows?.length) { - return ( - - ); - } - - return ( -
-
- {tableRows.map((item) => { - return ( - , - onClick: () => setEditItemId(item?.id), - }, - { - label: ( - handleDelete(item?.id)} - content="Want to delete this profile" - > - Delete - - ), - key: "delete", - icon: , - }, - ], - }} - trigger={["click"]} - placement="bottomRight" - > - - - } - > -
- -
-
- ); - })} -
-
- ); -} - -ListOfItems.propTypes = { - isLoading: PropTypes.bool.isRequired, - tableRows: PropTypes.array.isRequired, - setEditItemId: PropTypes.func.isRequired, - handleDelete: PropTypes.func.isRequired, - handleClick: PropTypes.func.isRequired, -}; - -export { ListOfItems }; From 8c6006d0a2eca4d877af371a28bba09c01915a1f Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 14:17:54 +0530 Subject: [PATCH 41/54] UN-4232 [FIX] Cap the document again, not the page range: a range costs full OCR A submit for pages 1-5 of a 400-page PDF under a 100-page cap was accepted with a 202, took a concurrency slot and staged the upload -- and the worker then failed it with "Document exceeds the 100-page limit", because the cloud side has always refused on `pages_total` while this side had moved to `pages_selected`. The premise behind that move was wrong, and the comment stating it was wrong in the file. "Pages 1-5 is five pages of OCR" is not how the engine works: it renders EVERY page (`convert_pdf_to_images(file_content, dir)`) and OCRs the whole file -- `X2Text.process` is handed `input_file_path` and no range at all -- and `load_pages(starting_page=, ending_page=)` filters afterwards, choosing only which rendered pages the agents read. A range selects what is EXTRACTED, never what is scanned or billed. Capping the range would have let a caller buy 400 pages of OCR under a 100-page limit. So the cap goes back to `pages_total`, which also puts the two repos back in agreement. `pages_selected` is still computed -- it is the honest count of what will be extracted, and the refusal message uses it to explain why a small range was refused anyway. Two tests asserted the old behaviour and have been rewritten rather than deleted: one now pins that a one-page range of a two-page document is still refused under a one-page cap, the other that this side compares the same quantity the worker does, so the two cannot drift apart again. Docs section 6.1 and the `AGENT_KV_MAX_PAGES` row said the same wrong thing and now carry the billing reason, plus a note that this is the line to revisit if OCR ever honours the range. Reported by @athul-rs on #2317 and unstract-cloud#1828 (blocking, both sides). 367 agent_kv tests pass. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_serializers.py | 41 ++++++++++----- .../agent_kv/tests/test_submit_serializer.py | 51 ++++++++++++++----- docs/agent-kv-api.md | 30 +++++++---- 3 files changed, 85 insertions(+), 37 deletions(-) diff --git a/backend/agent_kv/execution_serializers.py b/backend/agent_kv/execution_serializers.py index 5c2d2d0d53..e10ff167f3 100644 --- a/backend/agent_kv/execution_serializers.py +++ b/backend/agent_kv/execution_serializers.py @@ -473,23 +473,38 @@ def validate(self, data): f"{self.pages_total}-page document" } ) - # The cap bounds the work the job will DO, not the size of the file - # it was handed. A caller asking for pages 1-5 of a 400-page PDF is - # requesting five pages of OCR and extraction; refusing that against - # a 100-page cap rejected a request that was inside the documented - # limit. `pages_total` stays the measured document count -- it is - # what metering and the status document report -- and only the cap - # comparison moves to the selected range. + # The cap is on the DOCUMENT, not on the selected range, and the + # reason is billing rather than tidiness. + # + # This briefly compared `pages_selected` instead, on the premise + # that "pages 1-5 of a 400-page PDF is five pages of OCR". That + # premise is false. The engine renders EVERY page + # (`convert_pdf_to_images(file_content, dir)`) and OCRs the whole + # file -- `X2Text.process` takes `input_file_path` and no range at + # all -- and `load_pages(starting_page=, ending_page=)` filters + # afterwards, choosing only which rendered pages the agents look + # at. So pages 1-5 of a 400-page document costs 400 pages of OCR + # and is billed for 400. + # + # It also disagreed with the worker, which has always refused on + # `pages_total` (cloud `api_binding.py`). The caller got a 202, + # a concurrency slot and a staged upload, and then a failure -- + # the worst of both, since the refusal still arrived but only + # after the request looked accepted. Caught in review on #2317 / + # unstract-cloud#1828. + # + # `pages_selected` is still computed: it is the honest count of + # what will be EXTRACTED, and the message below uses it to explain + # why a small range was refused anyway. last_page = self.pages_total if end is None else min(end, self.pages_total) self.pages_selected = last_page - start + 1 - if self.pages_selected > settings.AGENT_KV_MAX_PAGES: + if self.pages_total > settings.AGENT_KV_MAX_PAGES: raise serializers.ValidationError( { - "file": f"Requested {self.pages_selected} pages " - f"(page_start={start}, page_end=" - f"{end if end is not None else self.pages_total}) of a " - f"{self.pages_total}-page document; max is " - f"{settings.AGENT_KV_MAX_PAGES} (§6.1)" + "file": f"Document has {self.pages_total} pages; max is " + f"{settings.AGENT_KV_MAX_PAGES} (§6.1). A page range does " + "not reduce this: the whole document is OCR'd and billed " + "regardless of page_start/page_end" } ) # Excel: no page concept pre-OCR (spec §6.1); pages_total stays None, diff --git a/backend/agent_kv/tests/test_submit_serializer.py b/backend/agent_kv/tests/test_submit_serializer.py index 473b667f4c..2ddc054c94 100644 --- a/backend/agent_kv/tests/test_submit_serializer.py +++ b/backend/agent_kv/tests/test_submit_serializer.py @@ -195,41 +195,64 @@ def test_page_cap_rejected(): assert "pages" in str(s.errors).lower() -def test_page_cap_counts_the_selected_range_not_the_whole_document(): - """The cap bounds the work the job will DO, not the size of the file. - - A caller asking for one page of a two-page document is requesting one page - of OCR and extraction. Counting the whole document rejected requests that - were inside the documented limit. Reported by Greptile on #2317. +def test_the_cap_counts_the_WHOLE_document_even_for_a_one_page_range(): + """A page range does not reduce what is OCR'd, so it must not reduce the cap. + + This test previously asserted the opposite, on the premise that "one page + of a two-page document is one page of OCR". The engine disagrees: it + renders every page (`convert_pdf_to_images(file_content, dir)`) and OCRs + the whole file -- `X2Text.process` takes `input_file_path` and no range -- + then `load_pages(starting_page=, ending_page=)` filters which rendered + pages the agents read. A range selects what is EXTRACTED, never what is + scanned or billed. + + It also disagreed with the worker, which refuses on `pages_total`, so the + caller got a 202, a slot and a staged upload before being failed anyway. + Caught in review on #2317 / unstract-cloud#1828; the Greptile finding the + old version cited had the same false premise. """ with mock.patch("agent_kv.execution_serializers.settings") as m: _defaults(m) m.AGENT_KV_MAX_PAGES = 1 s = SubmitSerializer(data=_data(page_start=1, page_end=1)) - assert s.is_valid(), s.errors - assert s.pages_selected == 1 - # The measured document count is unchanged -- it is what metering and - # the status document report, and only the cap comparison moved. - assert s.pages_total == 2 + + assert not s.is_valid() + assert "Document has 2 pages" in _errs(s) + assert "does not reduce this" in _errs(s) def test_an_open_ended_range_is_capped_at_the_last_page(): - """`page_end` past the end selects to the end, it does not inflate the count.""" + """`page_end` past the end selects to the end, it does not inflate the count. + + `pages_selected` is still computed and still correct -- it is the honest + count of what will be extracted, and the refusal message uses it. It is + simply not what the cap compares against. + """ with mock.patch("agent_kv.execution_serializers.settings") as m: _defaults(m) m.AGENT_KV_MAX_PAGES = 2 s = SubmitSerializer(data=_data(page_start=2, page_end=999)) assert s.is_valid(), s.errors assert s.pages_selected == 1 + assert s.pages_total == 2 -def test_a_selected_range_over_the_cap_is_still_rejected(): +def test_the_submit_cap_and_the_worker_cap_compare_the_same_quantity(): + """The cross-repo half of the bug, pinned so the two cannot drift again. + + `dispatch.py` sends `pages_total` and `max_pages`; the cloud worker + refuses when `pages_total > max_pages`. If this side ever caps something + else, a submit it accepts is one the worker rejects -- after the 202, + after the slot, after the upload. + """ with mock.patch("agent_kv.execution_serializers.settings") as m: _defaults(m) m.AGENT_KV_MAX_PAGES = 1 s = SubmitSerializer(data=_data(page_start=1, page_end=2)) + assert not s.is_valid() - assert "Requested 2 pages" in _errs(s) + # Same comparison the worker makes: measured document pages vs the cap. + assert "Document has 2 pages" in _errs(s) def test_page_start_past_the_end_of_the_document_is_rejected(): diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index c1a81e8201..951dfb91f0 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -328,15 +328,25 @@ Not exposed (D6): model choice, challenger model, `parallel_pages`, thinking bud `pdfplumber` and rejected over `AGENT_KV_MAX_PAGES` before any paid work runs. Excel has no pre-OCR page concept — it is capped by file size only at submit time (the engine enforces a post-OCR virtual-page cap). - - **The cap bounds the pages this request will PROCESS, not the document's - length.** It is checked against `pages_selected` — the count after - `page_start` / `page_end` are applied — not against `pages_total`. So pages - 1–50 of a 400-page PDF is accepted under a 100-page cap, while the same - document submitted without a page range is refused. That is the intended - reading: the cap exists to bound paid work per job, and a page range is the - caller declaring how much work they are asking for. `pages_total` is still - recorded on the job and returned in the status document, so the document's - real length is never lost. + - **The cap is on the DOCUMENT, and a page range does not reduce it.** It is + checked against `pages_total`, so pages 1–50 of a 400-page PDF is refused + under a 100-page cap exactly as the whole document would be. + + This reads as unfriendly until you look at what a range actually costs. The + engine renders every page and OCRs the whole file — `X2Text.process` is + given the input path and no range — and `page_start`/`page_end` then choose + which of the rendered pages the extraction agents read. So pages 1–5 of a + 400-page document costs 400 pages of OCR and is billed for 400. Capping the + range would let a caller buy 400 pages of OCR under a 100-page limit. + + The worker refuses on the same quantity (cloud `api_binding.py`), which is + the other half of the reason: a submit cap on anything else accepts + requests the worker then fails — after the 202, after the concurrency slot + and after the upload. + + `pages_selected` is still computed and reported; it is the honest count of + what will be EXTRACTED, and the refusal message uses it. If OCR ever honours + the range, this is the line to revisit. - Images (`.png .jpg .jpeg .tiff`) are **not** accepted, and no longer count as 1 page. The engine treats only `.pdf/.xlsx/.xls` as a document and `ImageLoader.load_pages` has no call site, so an accepted image returned @@ -1398,7 +1408,7 @@ with the default shown: |---|---|---| | `AGENT_KV_PATH_PREFIX` | `agent-kv` | Top-level public URL prefix (whitelisted past tenant middleware). | | `AGENT_KV_MAX_FILE_SIZE_MB` | `50` | Submit-time file size cap. | -| `AGENT_KV_MAX_PAGES` | `100` | Pre-OCR cap on the pages a request will PROCESS for PDFs — checked against `pages_selected` (after `page_start`/`page_end`), not the document's `pages_total` (§6.1). Excel has no pre-OCR page concept, so it takes the engine's post-OCR virtual-page cap instead; images are not accepted. | +| `AGENT_KV_MAX_PAGES` | `100` | Pre-OCR cap on the pages of the DOCUMENT for PDFs — checked against `pages_total`, **not** against the `page_start`/`page_end` range (§6.1), because the whole file is OCR'd and billed whatever the range. The cloud worker refuses on the same quantity. Excel has no pre-OCR page concept, so it takes the engine's post-OCR virtual-page cap instead; images are not accepted. | | `AGENT_KV_MAX_CALCULATIONS_BYTES` | `20000` | Byte cap on the optional `calculations` field. | | `AGENT_KV_MAX_SCHEMA_BYTES` | `262144` | Byte cap on the raw `keys` JSON document (256 KiB). | | `AGENT_KV_RESULT_TTL_DAYS` | `7` | Result retention window (and a cancelled job's input, which rides the same TTL — a completed/failed job's input is deleted immediately at finalize instead), stamped at submit time (see [§6](#9-retention-and-ttl)). | From 193a7af6800876c3f22e16fb9181d800c03006b6 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 14:26:23 +0530 Subject: [PATCH 42/54] UN-4232 [FIX] Phase 3 of the sweep could never select a row `AGENT_KV_STUCK_JOB_GRACE_SECONDS` and `rate_limiter.SLOT_TTL_SECONDS` are both 21600, and phase 3 used the first as its floor and the second as its ceiling. The window was `completed_at < now-21600 AND completed_at > now-21600` -- empty, and empty for ANY grace >= TTL. So a cancelled job whose executor died held its org's concurrency slot for the full six hours exactly as it would have with no phase 3 at all, and `released` was always 0. The ceiling was wrong in a second way. It asked whether Redis still holds the slot but read `completed_at`, the CANCEL time; slots are scored by acquisition at submit (`check_and_acquire` zadds `now` and trims below `now - SLOT_TTL`). A job submitted seven hours ago and cancelled a minute ago therefore looked eligible with nothing left to release, and would have been re-selected every sweep. Both bounds now read `dispatched_at`, with a dedicated `AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS` (default 3600) that must stay below the slot TTL. One hour rather than six because the job is ALREADY cancelled: a live executor learns of that at its next stage report and stops, so an hour of silence after dispatch is already strong evidence. Waiting six hours is indistinguishable from waiting for Redis to expire the slot, which is the behaviour this phase exists to improve on. The existing tests mock `AgentKVJob.objects` and assert filter kwargs, which is structurally blind to an empty window -- they stayed green throughout. Added `TestPhaseThreeActuallySelectsSomething`, DB-backed: a slot IS released, a just-dispatched job is left alone, a job past the TTL is left alone, the window is measured from dispatch rather than from the cancel, and the grace-below-TTL invariant is pinned so the two cannot meet again. The mocked test was updated to the corrected predicate and now also asserts that invariant. Mutation-checked: restoring the old bounds fails the DB-backed release test and the kwargs test together. Reported by @athul-rs on #2317. 372 agent_kv tests pass. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/maintenance.py | 34 ++++++-- backend/agent_kv/tests/test_sweeps.py | 121 ++++++++++++++++++++++++-- backend/backend/settings/base.py | 19 ++++ backend/sample.env | 1 + docs/agent-kv-api.md | 1 + 5 files changed, 161 insertions(+), 15 deletions(-) diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index 392f25dac8..1aa89ec73b 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -194,15 +194,34 @@ def run_sweep() -> dict: # The slot then sits occupied until Redis expires it. # # Bounded on BOTH sides, which is what keeps this from rescanning the same - # ancient rows forever: older than the stuck grace (so a live executor is - # not cut short) and newer than the slot TTL (past which Redis has already - # dropped the entry, so there is nothing left to release). + # ancient rows forever: old enough that the executor is presumed dead (so a + # live one is not cut short) and young enough that Redis still holds the + # slot (past the TTL there is nothing left to release). + # + # Both bounds read `dispatched_at`, and both of those are corrections. + # + # The floor used `AGENT_KV_STUCK_JOB_GRACE_SECONDS` against the slot TTL + # ceiling. Both default to 21600, so the window was + # `completed_at < now-21600 AND completed_at > now-21600` -- EMPTY, and + # empty for any grace >= TTL. This phase could never select a row and + # `released` was always 0, so an abandoned cancelled job held its org's + # slot for the full six hours exactly as it would have with no phase 3 at + # all. Hence the dedicated, shorter grace. + # + # The ceiling used `completed_at`, the CANCEL time, to decide whether Redis + # still holds the slot -- but the slot is scored by when it was ACQUIRED, + # at submit (`rate_limiter.check_and_acquire` zadds with `now` and trims + # below `now - SLOT_TTL`). A job submitted seven hours ago and cancelled a + # minute ago has a recent `completed_at` and no slot left to release, and + # the old bound would have selected it every sweep. + abandoned_grace = getattr(settings, "AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS", 3600) + abandoned_floor = now - timedelta(seconds=abandoned_grace) slot_ttl_floor = now - timedelta(seconds=SLOT_TTL_SECONDS) abandoned_cancelled = AgentKVJob.objects.filter( status=JobStatus.CANCELLED, dispatched_at__isnull=False, - completed_at__lt=stuck_cutoff, - completed_at__gt=slot_ttl_floor, + dispatched_at__lt=abandoned_floor, + dispatched_at__gt=slot_ttl_floor, # NEWEST first, the opposite of the other two phases and deliberate. # Releasing a slot does not remove its row from this query -- there is # no "released" marker -- so oldest-first would re-select the same 500 @@ -216,7 +235,10 @@ def run_sweep() -> dict: # while its release still matters, unless more than # `_MAINTENANCE_BATCH_LIMIT` become eligible inside one sweep interval # -- and the TTL remains the backstop for that. - ).order_by("-completed_at")[:_MAINTENANCE_BATCH_LIMIT] + # + # Ordered by the same column the window filters on, so "newest" means + # the same thing in both. + ).order_by("-dispatched_at")[:_MAINTENANCE_BATCH_LIMIT] released = 0 for job in abandoned_cancelled: diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py index e5fb833324..7744e3fab5 100644 --- a/backend/agent_kv/tests/test_sweeps.py +++ b/backend/agent_kv/tests/test_sweeps.py @@ -48,6 +48,7 @@ from agent_kv import internal_views as iv # noqa: E402 from agent_kv import maintenance # noqa: E402 from agent_kv.models import AgentKVJob, JobStatus # noqa: E402 +from agent_kv.rate_limiter import SLOT_TTL_SECONDS # noqa: E402 def _post(path, body=None): @@ -828,10 +829,15 @@ def test_a_quiet_sweep_still_records_that_it_RAN( @mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release") @mock.patch.object(AgentKVJob, "objects") def test_phase_three_is_bounded_on_both_sides(m_objects, m_release): - """Older than the stuck grace, so a live executor is not cut short; and - newer than the slot TTL, past which Redis has already dropped the entry and - there is nothing left to release. Without the second bound this would - rescan every cancelled job ever, forever. + """Old enough that the executor is presumed dead, young enough that Redis + still holds the slot -- and BOTH bounds read `dispatched_at`. + + This test used to assert the stuck grace against `completed_at`, which was + wrong twice over and could not notice either: the two constants were equal + so the window was empty, and the ceiling asked about slot age using the + cancel time when slots are scored from acquisition at submit. Asserting + kwargs cannot see an empty window, which is why + `TestPhaseThreeActuallySelectsSomething` exists below. """ frozen_now = timezone.now() _wire_sweep_phases(m_objects) @@ -842,13 +848,16 @@ def test_phase_three_is_bounded_on_both_sides(m_objects, m_release): kwargs = m_objects.filter.call_args_list[2].kwargs assert kwargs["status"] == JobStatus.CANCELLED assert kwargs["dispatched_at__isnull"] is False - stuck_cutoff = frozen_now - timedelta( - seconds=settings.AGENT_KV_STUCK_JOB_GRACE_SECONDS + assert kwargs["dispatched_at__lt"] == frozen_now - timedelta( + seconds=settings.AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS ) - assert kwargs["completed_at__lt"] == stuck_cutoff - assert kwargs["completed_at__gt"] == frozen_now - timedelta( + assert kwargs["dispatched_at__gt"] == frozen_now - timedelta( seconds=maintenance.SLOT_TTL_SECONDS ) + # The bound that makes the window non-empty at all. + assert ( + settings.AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS < maintenance.SLOT_TTL_SECONDS + ) def test_phase_three_takes_the_newest_eligible_rows_first(): @@ -874,7 +883,7 @@ def test_phase_three_takes_the_newest_eligible_rows_first(): m_objects.filter.side_effect = [phase1, phase2_status, phase3] maintenance.run_sweep() - phase3.order_by.assert_called_once_with("-completed_at") + phase3.order_by.assert_called_once_with("-dispatched_at") # --------------------------------------------------------------------------- @@ -999,3 +1008,97 @@ def test_the_predicate_is_declared_once_for_both_statements(self): "status": JobStatus.PENDING, "dispatched_at__isnull": True, } + + +class TestPhaseThreeActuallySelectsSomething(TestCase): + """Phase 3 against a real database, because a mock cannot see an empty window. + + The mocked tests above assert the filter KWARGS the query is built with. + That is blind to whether those kwargs can ever match: phase 3 bounded + `completed_at` below by `AGENT_KV_STUCK_JOB_GRACE_SECONDS` and above by + `SLOT_TTL_SECONDS`, both 21600, so the window was + `completed_at < now-21600 AND completed_at > now-21600` -- empty for any + grace >= TTL. The phase could never release a slot and `released` was + always 0, while every mocked assertion stayed green. Caught in review by + @athul-rs on #2317. + + The second bug the same tests could not see: the ceiling asked whether + Redis still holds the slot, but read the CANCEL time. Slots are scored by + acquisition, at submit, so a job submitted long ago and cancelled a moment + ago looked eligible and had nothing left to release. + """ + + def setUp(self): + self.org = Organization.objects.create( + display_name="Sweep3", organization_id="org_sweep3", name="sweep3" + ) + UserContext.set_organization_identifier(None) + + def _cancelled(self, *, dispatched_ago, completed_ago=60): + job = AgentKVJob( + organization_id=self.org.id, + status=JobStatus.CANCELLED, + extractor="table", + ) + job.save() + now = timezone.now() + AgentKVJob.objects.filter(id=job.id).update( + dispatched_at=now - timedelta(seconds=dispatched_ago), + completed_at=now - timedelta(seconds=completed_ago), + ) + return job + + def _sweep(self): + with mock.patch.object( + maintenance.AgentKVConcurrencyLimiter, "release" + ) as m_release: + result = maintenance.run_sweep() + return result, m_release + + def test_an_abandoned_cancelled_job_has_its_slot_released(self): + """The whole point of the phase, and it had never once happened.""" + job = self._cancelled(dispatched_ago=2 * 3600) + + result, m_release = self._sweep() + + assert result["released"] == 1, result + m_release.assert_called_once_with(str(self.org.id), str(job.id)) + + def test_a_job_dispatched_moments_ago_is_left_alone(self): + """The floor still protects a live executor.""" + self._cancelled(dispatched_ago=60) + + result, m_release = self._sweep() + + assert result["released"] == 0 + assert not m_release.called + + def test_a_job_whose_slot_redis_has_already_dropped_is_left_alone(self): + """Past the slot TTL there is nothing to release, so selecting it would + mean re-releasing the same rows on every sweep forever. + """ + self._cancelled(dispatched_ago=SLOT_TTL_SECONDS + 3600) + + result, m_release = self._sweep() + + assert result["released"] == 0 + + def test_the_window_is_measured_from_dispatch_not_from_the_cancel(self): + """Submitted long ago, cancelled a minute ago: the slot is gone. + + The old ceiling read `completed_at`, so this row looked eligible -- + recent cancel, inside the TTL -- when its slot had expired hours + earlier. It would have been re-selected every sweep. + """ + self._cancelled(dispatched_ago=SLOT_TTL_SECONDS + 3600, completed_ago=60) + + result, _ = self._sweep() + + assert result["released"] == 0 + + def test_the_grace_is_its_own_setting_and_shorter_than_the_slot_ttl(self): + """The invariant that keeps the window non-empty. If these ever meet + again, phase 3 silently stops working and every test above still + passes except the first. + """ + assert settings.AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS < SLOT_TTL_SECONDS diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index 6c510e467f..7c9f497bfc 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -241,6 +241,25 @@ def get_required_setting(setting_key: str, default: str | None = None) -> str | AGENT_KV_STUCK_JOB_GRACE_SECONDS = int( os.environ.get("AGENT_KV_STUCK_JOB_GRACE_SECONDS", 21600) ) +#: How long after DISPATCH a cancelled job's executor is presumed dead, so the +#: sweep may release the concurrency slot it still holds. +#: +#: Its own setting, and deliberately shorter than both the stuck grace and +#: `rate_limiter.SLOT_TTL_SECONDS`. Phase 3 of the sweep used the stuck grace +#: as its floor and the slot TTL as its ceiling; both default to 21600, so the +#: window was `completed_at < now-21600 AND completed_at > now-21600` -- empty, +#: and empty for ANY grace >= TTL. The phase could never select a row, and +#: `released` was always 0. +#: +#: One hour rather than six because the job is already CANCELLED: a live +#: executor learns of that at its next stage report and stops, so an hour of +#: silence after dispatch is already strong evidence that nothing is coming. +#: Waiting the full six hours is indistinguishable from waiting for Redis to +#: expire the slot on its own, which is the behaviour this phase exists to +#: improve on. +AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS = int( + os.environ.get("AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS", 3600) +) AGENT_KV_CALCULATIONS_ENABLED = ( os.environ.get("AGENT_KV_CALCULATIONS_ENABLED", "false").lower() == "true" ) diff --git a/backend/sample.env b/backend/sample.env index af307fb410..e9175348df 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -200,6 +200,7 @@ AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE=60 # Per-key request rate limit AGENT_KV_LIMITER_FAIL_OPEN=false # true = both limiters above allow the request when Redis is down (default: refuse with 429) AGENT_KV_SWEEP_GRACE_SECONDS=3600 # Age before a never-dispatched PENDING job is swept AGENT_KV_STUCK_JOB_GRACE_SECONDS=21600 # Age (from dispatch) before a stuck DISPATCHED/RUNNING job is force-failed (6h) +AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS=3600 # Age (from dispatch) before a CANCELLED job's executor is presumed dead and its slot released; must be < the 6h slot TTL AGENT_KV_CALCULATIONS_ENABLED=false # Gates the submit `calculations` field; engine can't run it yet AGENT_KV_STRUCTURED_OUTPUT_ENABLED=false # Gates the submit `structured_output` field; engine can't run it yet diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 951dfb91f0..23ee386ebf 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -1408,6 +1408,7 @@ with the default shown: |---|---|---| | `AGENT_KV_PATH_PREFIX` | `agent-kv` | Top-level public URL prefix (whitelisted past tenant middleware). | | `AGENT_KV_MAX_FILE_SIZE_MB` | `50` | Submit-time file size cap. | +| `AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS` | `3600` | How long after DISPATCH a cancelled job's executor is presumed dead, so the sweep's phase 3 may release the concurrency slot it still holds. Must stay **below** `rate_limiter.SLOT_TTL_SECONDS` (6 h) or the window is empty and the phase silently releases nothing. | | `AGENT_KV_MAX_PAGES` | `100` | Pre-OCR cap on the pages of the DOCUMENT for PDFs — checked against `pages_total`, **not** against the `page_start`/`page_end` range (§6.1), because the whole file is OCR'd and billed whatever the range. The cloud worker refuses on the same quantity. Excel has no pre-OCR page concept, so it takes the engine's post-OCR virtual-page cap instead; images are not accepted. | | `AGENT_KV_MAX_CALCULATIONS_BYTES` | `20000` | Byte cap on the optional `calculations` field. | | `AGENT_KV_MAX_SCHEMA_BYTES` | `262144` | Byte cap on the raw `keys` JSON document (256 KiB). | From 6ad71f075d190ec3df6a776900d001150ff7e69e Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 14:30:49 +0530 Subject: [PATCH 43/54] UN-4232 [FIX] Refuse an AGENT_KV_PATH_PREFIX that would disable authentication The execution API authenticates in its views, so its prefix goes into `WHITELISTED_PATHS` -- and `CustomAuthMiddleware` matches that list with `startswith`, which makes the value security-relevant in a way an env var normally is not. Verified both failure modes against the real settings: AGENT_KV_PATH_PREFIX="" -> entry "/" -> every path exempt AGENT_KV_PATH_PREFIX="api" -> entry "/api" -> /api/v1/... exempt AGENT_KV_PATH_PREFIX="api/v1" -> entry "/api/v1" -> the tenant API exempt The second case takes key management with it, so the endpoint that mints Agent-KV keys would stop requiring a session. Both are operator mistakes rather than attacks, and both are silent: the server starts, every request succeeds, and nothing anywhere says the service is now open. Now normalised and validated at import, raising `ImproperlyConfigured` -- Django's own signal, which aborts the boot, the only outcome that cannot be missed. Refused: empty, whitespace-only, bare slashes, and any value equal to or an ancestor of `PATH_PREFIX` or `API_DEPLOYMENT_PATH_PREFIX`. A DESCENDANT is still allowed (`api/v1/unstract/agent-kv` exempts only itself), as is a value that merely shares leading characters (`deploy` vs `deployment`). Extracted as `validate_agent_kv_path_prefix` rather than left inline so the bad values can be enumerated in a test instead of only the good one being checked -- 17 cases, including a guard-the-guard assertion that the shipped default is itself accepted and present in the whitelist. Deliberately NOT changed: the entry stays `/{prefix}` rather than `/{prefix}/`. The trailing slash would stop `/agent-kv-anything` matching, but nothing routes there, so the only effect I could demonstrate was turning the documented `POST /agent-kv` -> 301 -> `/agent-kv/` redirect into a 401, since CommonMiddleware only appends the slash after the view 404s, by which time auth has already run. Happy to add it if the reviewer still wants it. Mutation-checked: dropping either half of the validation fails 5 of 17. Reported by @athul-rs on #2317. 389 agent_kv tests pass. Co-Authored-By: Claude Opus 5 --- .../agent_kv/tests/test_path_prefix_guard.py | 85 +++++++++++++++++++ backend/backend/settings/base.py | 52 +++++++++++- 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 backend/agent_kv/tests/test_path_prefix_guard.py diff --git a/backend/agent_kv/tests/test_path_prefix_guard.py b/backend/agent_kv/tests/test_path_prefix_guard.py new file mode 100644 index 0000000000..1a99d53ab3 --- /dev/null +++ b/backend/agent_kv/tests/test_path_prefix_guard.py @@ -0,0 +1,85 @@ +"""`AGENT_KV_PATH_PREFIX` is whitelisted out of authentication, so it is input. + +The execution API authenticates in its views, which means its prefix is added +to `WHITELISTED_PATHS` and `CustomAuthMiddleware` skips anything matching it — +by `startswith`, not by equality. That makes the value security-relevant in a +way an env var normally is not: + +* empty or whitespace-only produces the entry `/`, a prefix of **every** path + on the service, so nothing is authenticated at all; +* a value that collides with another mounted prefix (`api`, `api/v1`, + `deployment`) exempts that whole surface instead. + +Both are operator mistakes rather than attacks, and both are silent — the +server starts, every request succeeds, and no log line says the API is now +open. Reported by @athul-rs on #2317. +""" + +import os + +import django +import pytest +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from django.core.exceptions import ImproperlyConfigured # noqa: E402 + +from backend.settings.base import validate_agent_kv_path_prefix # noqa: E402 + +_PATH = "api/v1" +_DEPLOY = "deployment" + + +def _validate(raw): + return validate_agent_kv_path_prefix(raw, _PATH, _DEPLOY) + + +@pytest.mark.parametrize("raw", ["", " ", "/", "//", None]) +def test_an_empty_prefix_is_refused(raw): + """`/` matches every path. This is the one that turns the whole service + anonymous, so it must not be reachable by leaving a value blank. + """ + with pytest.raises(ImproperlyConfigured, match="must not be empty"): + _validate(raw) + + +@pytest.mark.parametrize("raw", ["api", "api/v1", "deployment", "/api/", "api/v1/"]) +def test_a_prefix_that_swallows_another_surface_is_refused(raw): + """Equal to, or an ancestor of, a prefix that is NOT whitelisted. `api` + exempts `/api/v1/...`, i.e. the entire tenant API including key + management. + """ + with pytest.raises(ImproperlyConfigured, match="collides with"): + _validate(raw) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("agent-kv", "agent-kv"), + ("/agent-kv/", "agent-kv"), + (" agent-kv ", "agent-kv"), + ("agent-kv/v2", "agent-kv/v2"), + # A DESCENDANT of another prefix is fine: `/api/v1/...` does not start + # with `/api/v1/unstract/agent-kv`, so nothing extra is exempted. + ("api/v1/unstract/agent-kv", "api/v1/unstract/agent-kv"), + # Shares a textual prefix with `deployment` but is not an ancestor of + # it, so it exempts only itself. + ("deploy", "deploy"), + ], +) +def test_a_safe_prefix_is_accepted_and_normalised(raw, expected): + assert _validate(raw) == expected + + +def test_the_default_is_safe(): + """Guards the guard: every refusal above would still pass if the shipped + default were itself rejected, and nobody would notice until deploy. + """ + from django.conf import settings + + assert settings.AGENT_KV_PATH_PREFIX == "agent-kv" + assert f"/{settings.AGENT_KV_PATH_PREFIX}" in settings.WHITELISTED_PATHS diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index 7c9f497bfc..0ac6a12d09 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -15,6 +15,7 @@ from pathlib import Path from urllib.parse import quote +from django.core.exceptions import ImproperlyConfigured from django.core.validators import URLValidator from dotenv import find_dotenv, load_dotenv from utils.common_utils import CommonUtils @@ -206,8 +207,57 @@ def get_required_setting(setting_key: str, default: str | None = None) -> str | os.environ.get("API_DEPLOYMENT_RATE_LIMIT_LOCK_BLOCKING_TIMEOUT", 5) ) + # Agent-KV API -AGENT_KV_PATH_PREFIX = os.environ.get("AGENT_KV_PATH_PREFIX", "agent-kv") +# +# Validated at import, because this value is added to `WHITELISTED_PATHS` and +# `CustomAuthMiddleware` matches that list with `startswith`. An empty or +# whitespace-only value makes the entry `/`, which is a prefix of every path on +# the service -- authentication would be skipped for ALL of them. A value that +# collides with another mounted prefix (`api`, `api/v1`, `deployment`) exempts +# that whole surface instead. Both are operator mistakes rather than attacks, +# and both are silent: the server starts, every request succeeds, and nothing +# in any log says the API is now unauthenticated. +# +# Refused loudly at startup instead. `ImproperlyConfigured` is Django's own +# signal for this and aborts the boot, which is the only outcome that cannot +# be missed. +def validate_agent_kv_path_prefix(raw, path_prefix, deployment_prefix): + """Normalise and refuse an unsafe `AGENT_KV_PATH_PREFIX`. + + A function rather than inline statements so it is testable without + re-importing the settings module under a patched environment -- the + behaviour being guarded is an auth bypass, and it deserves a test that + enumerates the bad values rather than one that can only check the good one. + """ + prefix = (raw or "").strip().strip("/") + if not prefix: + raise ImproperlyConfigured( + "AGENT_KV_PATH_PREFIX must not be empty: it is whitelisted out of " + "authentication by prefix match, so an empty value exempts every " + "path on this service." + ) + for other_name, other in ( + ("PATH_PREFIX", path_prefix), + ("API_DEPLOYMENT_PATH_PREFIX", deployment_prefix), + ): + # Equal, or an ancestor of the other: either way one whitelist entry + # swallows the other surface. + if prefix == other or other.startswith(f"{prefix}/"): + raise ImproperlyConfigured( + f"AGENT_KV_PATH_PREFIX={prefix!r} collides with " + f"{other_name}={other!r}. It is whitelisted out of " + "authentication by prefix match, so this would exempt that " + "surface too." + ) + return prefix + + +AGENT_KV_PATH_PREFIX = validate_agent_kv_path_prefix( + os.environ.get("AGENT_KV_PATH_PREFIX", "agent-kv"), + PATH_PREFIX, + API_DEPLOYMENT_PATH_PREFIX, +) # Bucket-rooted object-store root for staged inputs/results, mirroring # WORKFLOW_EXECUTION_DIR_PREFIX / API_EXECUTION_DIR_PREFIX: the FIRST segment is # the bucket (s3fs/gcsfs treat it that way), so it must already exist. MUST match From f39640f00900f3bb5befaacb103724f441f32127 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 14:34:06 +0530 Subject: [PATCH 44/54] UN-4232 [FIX] Refuse an undeliverable webhook_url at submit, and stop retrying it `URLField` accepts `http://`, `localhost` and private address literals. The sink accepts none of them: `send_webhook` is called without `allow_http`, so it permits https to public hosts only. The caller therefore got a 202 and a webhook that never arrived, with nothing in the response contradicting the "https-only" line in the docs. The cancel path made it worse. `send_webhook` returns False for three different things -- a non-2xx response, a connection failure, and a URL its SSRF guard refuses -- and `agent_kv_cancelled` treated all three as a delivery failure worth retrying. A refusal is a property of the URL, not of the network, so it burned the full ~20-attempt budget and ended in a poison drop for a caller who simply gave an http:// hook. Both halves fixed: * `SubmitSerializer.validate_webhook_url` applies the sink's own rule with `webhook_url_refusal(allowed_schemes=("https",), resolve=False)`, the same shape `notification_v2.serializers` uses. `resolve=False` keeps DNS off the request thread -- `getaddrinfo` honours no timeout -- so a hostname pointing inward is still caught at the sink, while scheme, credentials-in-URL, parser disagreement and address literals are all refused up front. * `agent_kv_cancelled` asks `is_retryable_refusal` before raising. A permanently undeliverable URL is logged at ERROR and the message acknowledged; only a genuinely retryable failure still raises. Keys created before this change, and resolvers that start answering differently, can both still reach that path. Tests cover five refused shapes and the two accepted ones (including blank, which means "do not notify" and must not become an error), plus a guard that the submit check and the sink stay on the same scheme list. Docs section 8 and the submit-field table now say the rule is enforced at submit, not only at delivery. Reported by @athul-rs on #2317. 397 agent_kv and 29 ide_callback tests pass. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_serializers.py | 28 +++++++++ .../agent_kv/tests/test_submit_serializer.py | 57 +++++++++++++++++++ docs/agent-kv-api.md | 14 +++-- workers/ide_callback/agent_kv_tasks.py | 33 +++++++++-- 4 files changed, 122 insertions(+), 10 deletions(-) diff --git a/backend/agent_kv/execution_serializers.py b/backend/agent_kv/execution_serializers.py index e10ff167f3..07f9946fb5 100644 --- a/backend/agent_kv/execution_serializers.py +++ b/backend/agent_kv/execution_serializers.py @@ -8,6 +8,7 @@ from agent_kv.constants import EXTRACTOR_ROUTES, TABLE_EXTRACTOR_NAME, V1_EXTRACTOR_NAME from unstract.agent_kv_schema import SchemaError, compile_schema +from unstract.core.network.ssrf import webhook_url_refusal from unstract.sdk1.constants import AdapterTypes # Images are deliberately ABSENT, and that is a cross-repo contract, not an @@ -367,6 +368,33 @@ class SubmitSerializer(serializers.Serializer): required=False, allow_blank=True, default="", max_length=1024 ) + def validate_webhook_url(self, value): + """Refuse at submit what the sink will refuse at delivery. + + `URLField` alone accepts `http://`, `localhost` and private address + literals, none of which this API will ever dial: `send_webhook` is + called without `allow_http`, so the sink allows https to public hosts + only. The caller therefore got a 202 and a webhook that never arrived, + with nothing in the response contradicting the "https-only" promise in + the docs -- and on the cancel path `agent_kv_cancelled` treats the + refusal as a delivery failure and burns its whole retry budget on a + URL that cannot work. + + `resolve=False`, matching `notification_v2.serializers`: DNS has no + timeout and this runs on the request thread, so a hostname pointing + inward is accepted here and refused at the sink, which does resolve. + The syntactic checks -- scheme, credentials in the URL, parser + disagreement, address literals -- all still run. + """ + if not value: + return value + reason = webhook_url_refusal(value, allowed_schemes=("https",), resolve=False) + if reason is not None: + raise serializers.ValidationError( + "webhook_url must be an https URL on a public host " f"({reason})" + ) + return value + #: Measured page count of the uploaded document. None for Excel, which has #: no pre-OCR page concept. This is what metering and the status document #: report, and it is NOT what the page cap is compared against. diff --git a/backend/agent_kv/tests/test_submit_serializer.py b/backend/agent_kv/tests/test_submit_serializer.py index 2ddc054c94..f7f6c27a74 100644 --- a/backend/agent_kv/tests/test_submit_serializer.py +++ b/backend/agent_kv/tests/test_submit_serializer.py @@ -605,3 +605,60 @@ def test_neither_serializer_still_carries_a_compiled_attribute(): f"{cls.__name__}.compiled was dead: `dispatch_job` sends the raw " "keys dict, and the compiled form cannot cross the queue anyway" ) + + +# --------------------------------------------------------------------------- +# webhook_url: refuse at submit what the sink refuses at delivery. +# --------------------------------------------------------------------------- +# +# `URLField` alone accepts `http://`, `localhost` and private literals, and +# `send_webhook` is called without `allow_http`, so the sink allows https to +# public hosts only. The caller got a 202 and a webhook that never arrived — +# and on the cancel path the refusal looked like a delivery failure, so +# `agent_kv_cancelled` retried a URL that could never work until it poisoned. +# Reported by @athul-rs on #2317. + + +@pytest.mark.parametrize( + "url", + [ + "http://example.com/hook", # scheme the sink will not dial + "https://localhost/hook", + "https://127.0.0.1/hook", + "https://10.0.0.5/hook", + "https://user:pw@example.com/hook", # credentials in the URL + ], +) +def test_a_webhook_url_the_sink_would_refuse_is_rejected_at_submit(url): + s = SubmitSerializer(data=_data(webhook_url=url)) + + assert not s.is_valid() + assert "webhook_url" in s.errors + + +@pytest.mark.parametrize("url", ["https://example.com/hook", ""]) +def test_a_deliverable_webhook_url_is_accepted(url): + """Including the empty string: the field is optional and blank means + "do not notify", which must not become a validation error. + """ + s = SubmitSerializer(data=_data(webhook_url=url)) + + assert s.is_valid(), s.errors + + +def test_the_submit_check_matches_what_the_sink_allows(): + """Guards the pair. The sink calls `send_webhook` with no `allow_http`, so + it permits https only; if this side ever widened to http the caller would + be back to a 202 and silence. + """ + import inspect + + from agent_kv import execution_serializers + + source = inspect.getsource( + execution_serializers.SubmitSerializer.validate_webhook_url + ) + + assert 'allowed_schemes=("https",)' in source + # DNS stays off the request thread; `getaddrinfo` honours no timeout. + assert "resolve=False" in source diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 23ee386ebf..96f3e04851 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -225,7 +225,7 @@ The request is **extractor-scoped**: a per-extractor schema and its knobs live i | `page_start`, `page_end` | no | 1-based inclusive range. **Job-level, not per-extractor**: the range drives the shared OCR pass and the page cap, so it cannot differ between extractors reading the same document. | | `timeout` | no | Omitted or `0` = pure async (immediate 202). `1–300`: the view polls the job row up to the deadline and returns the result inline if the job completes, else 202 with the job id. | | `tags`, `custom_data` | no | Echoed through. | -| `webhook_url` | no | Terminal-state POST `{job_id, status}` only — no result payload. | +| `webhook_url` | no | Terminal-state POST `{job_id, status}` only — no result payload. **https, public host**; anything the delivery guard would refuse is a 400 at submit (§8). | **Each `extractors` entry:** @@ -900,10 +900,14 @@ own module docstring says so: second) slips through this gap. The module's own docstring names this as a known, accepted risk. **Fast-follow, not shipped:** IP-pinning the validated address into the actual connection, so the two resolutions can never disagree. -- **Scheme: https-only**, in this feature's actual usage. `send_webhook` exposes a - generic `allow_http` escape hatch (for explicitly configured dev environments - elsewhere), but the Agent-KV caller (`_maybe_webhook`) never passes it — every - Agent-KV webhook is https-only in practice. +- **Scheme: https-only**, and now refused at SUBMIT rather than only at delivery. + `send_webhook` exposes a generic `allow_http` escape hatch (for explicitly + configured dev environments elsewhere), but the Agent-KV caller + (`_maybe_webhook`) never passes it, so the sink allows https to public hosts + only. `SubmitSerializer.validate_webhook_url` applies the same rule up front + with `resolve=False` — a URL the sink could never dial is a 400 on the submit + instead of a 202 followed by silence. DNS stays off the request thread, so a + hostname that resolves inward is still caught at the sink rather than here. - **No redirects** (`allow_redirects=False`). - **Fixed, minimal payload** (`{job_id, status}`, plus `result_available` on the one follow-up described above — never result content) and the **response body is diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index 5ac699b7a1..81699ea1e2 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -18,6 +18,11 @@ from queue_backend import worker_task from shared.utils.webhook_notify import send_webhook +from unstract.core.network.ssrf import ( + is_retryable_refusal, + webhook_url_refusal, +) + logger = logging.getLogger(__name__) _UNKNOWN = "Executor failed without an error message" @@ -257,11 +262,29 @@ def agent_kv_cancelled( logger.exception("agent_kv_cancelled webhook failed: job_id=%s", job_id) raise if not delivered: - # `send_webhook` returns False for a non-2xx response or a connection - # failure. Returning success here acknowledged the queue message, so - # the caller simply never received the terminal notification this task - # exists to deliver -- and nothing retried or recorded it. Raise so the - # retry budget applies, exactly as a transport exception would. + # `send_webhook` returns False for THREE different things: a non-2xx + # response, a connection failure, and a URL its SSRF guard refuses. + # Only the first two are worth retrying. + # + # Retrying a refused URL cannot ever succeed -- the reason is a + # property of the URL, not of the network -- so it burned the full + # retry budget (~20 attempts) and ended in a poison drop, for a + # caller who simply gave an `http://` or private-host webhook. Submit + # now refuses those up front, but a key created before that, or a + # resolver that starts answering differently, can still reach here. + reason = webhook_url_refusal(url, allowed_schemes=("https",)) + if reason is not None and not is_retryable_refusal(reason): + # Logged at ERROR, not swallowed quietly: the caller still did not + # get their notification, and that is worth seeing. But the queue + # message is acknowledged, because nothing about repeating this + # will change the outcome. + logger.error( + "agent_kv_cancelled: webhook_url for job %s is permanently " + "undeliverable (%s); not retrying", + job_id, + reason, + ) + return {"job_id": job_id, "status": "cancelled", "webhook_skipped": reason} logger.error( "agent_kv_cancelled: webhook not delivered for job %s (retry %s of %s)", job_id, From 9a480d44798d33c8b27d7a5c037b1e5f95886aee Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 14:36:17 +0530 Subject: [PATCH 45/54] UN-4232 [FIX] Never read the webhook response body; it can hold an ide_callback slot `timeout` in `requests` is per socket operation, not total, and `send_webhook` read the response BODY before returning. Delivery runs inline on the `worker-pg-ide-callback` consumer -- concurrency 2 by default, and shared with Prompt Studio's own `ide_callback` queue -- so a receiver that answers 200 and then trickles one byte every nine seconds, or returns a very large body, held one of those two slots for as long as it liked, resetting the read budget on every chunk. Cheap for any key holder to arrange: submit, cancel immediately. That spends no LLM budget and still enqueues `agent_kv_cancelled`, which delivers a webhook. The docs already claimed "the response body is never read". It was aspirational -- nothing asked `requests` not to. Now `stream=True` with the response closed in a `finally`, so only the status line and headers are consumed and the connection is released without draining. The timeout is an explicit `(5, 10)` connect/read pair rather than a scalar, because the scalar read as one total budget and was not. What this does NOT fix, said plainly in the code and in docs section 8: a peer that trickles HEADERS can still extend a single delivery, since each read resets its own budget. Removing that needs delivery to leave this consumer for `notifications_webhook`, the way pipeline notifications already work -- a queue change rather than a client one, so it is recommended on the PR rather than done here. Reported by @athul-rs on #2317. 49 shared + ide_callback tests pass. Co-Authored-By: Claude Opus 5 --- docs/agent-kv-api.md | 15 +++++- workers/shared/tests/test_webhook_notify.py | 58 ++++++++++++++++++++- workers/shared/utils/webhook_notify.py | 37 +++++++++++-- 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 96f3e04851..039e623954 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -911,8 +911,19 @@ own module docstring says so: - **No redirects** (`allow_redirects=False`). - **Fixed, minimal payload** (`{job_id, status}`, plus `result_available` on the one follow-up described above — never result content) and the **response body is - never read** — only the status code is inspected. -- **Short timeout** (10 seconds). + never read**: the POST is made with `stream=True` and the response closed in a + `finally`, so only the status line and headers are consumed. Before that the + claim was aspirational — `requests` reads the body before returning unless you + ask it not to — and since this runs inline on the `ide_callback` consumer + (concurrency 2, shared with Prompt Studio), a receiver returning a huge or + trickled body could hold one of those slots indefinitely. +- **Timeouts are per-socket-operation, not total** — 5 s connect, 10 s read. That + is a `requests` property, not a choice: a peer that keeps trickling resets the + read budget on every chunk. The body is never read (above), which removes the + unbounded case; a server that trickles *headers* can still extend one delivery. + The structural fix is to move delivery onto the `notifications_webhook` worker + the way pipeline notifications do, so a slow receiver cannot occupy an + `ide_callback` slot at all — not done here, because it is a queue change. - **No retries.** One POST attempt only; any failure (host refused by the pre-check, timeout, connection error, non-2xx status) is logged and swallowed — there is no retry loop anywhere in this delivery path. diff --git a/workers/shared/tests/test_webhook_notify.py b/workers/shared/tests/test_webhook_notify.py index 1c0fddfb9b..3424e3cd5f 100644 --- a/workers/shared/tests/test_webhook_notify.py +++ b/workers/shared/tests/test_webhook_notify.py @@ -123,7 +123,10 @@ def test_public_host_posted_no_redirects(m_gai, m_requests): assert wn.send_webhook("https://example.com/hook", {"job_id": "j"}) is True kw = m_requests.post.call_args.kwargs assert kw["allow_redirects"] is False - assert kw["timeout"] == 10 + # A (connect, read) pair, not a scalar. The scalar read as one total + # budget and was not: `requests` applies it per socket operation. + assert kw["timeout"] == (wn._CONNECT_TIMEOUT, wn._READ_TIMEOUT) + assert kw["stream"] is True @mock.patch.object(wn, "requests") @@ -219,3 +222,56 @@ def test_default_still_refuses_http_and_private(self, m_requests): is False ) assert not m_requests.post.called + + +# --------------------------------------------------------------------------- +# The response body is never read. +# --------------------------------------------------------------------------- + + +def test_the_body_is_never_read_and_the_response_is_closed(monkeypatch): + """`timeout` is per-read, not total, so reading the body is unbounded work. + + This runs inline on the `ide_callback` consumer -- concurrency 2 by + default and shared with Prompt Studio -- so a receiver that answers 200 + and then trickles one byte every nine seconds, or returns a very large + body, held one of those two slots for as long as it liked while resetting + the read timeout on every chunk. Cheap to trigger with any API key: + submit, cancel immediately, and the cancellation webhook fires with no LLM + spend. Reported by @athul-rs on #2317. + + Asserting `stream=True` AND the close, because either alone is + insufficient: without `stream` the body is already read by the time the + call returns, and without the close the connection is held until GC. + """ + captured = {} + + class _Resp: + status_code = 200 + + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + @property + def text(self): # pragma: no cover - must never be reached + raise AssertionError("the response body must not be read") + + resp = _Resp() + + def _post(url, **kwargs): + captured.update(kwargs) + return resp + + monkeypatch.setattr(wn.requests, "post", _post) + monkeypatch.setattr(wn, "is_safe_webhook_url", lambda *a, **k: True) + + assert wn.send_webhook("https://example.com/hook", {"job_id": "j"}) + + assert captured["stream"] is True + assert resp.closed, "the connection must be released without draining the body" + # A tuple, so connect and read read as separate budgets rather than one + # total -- which is what the old scalar looked like. + assert isinstance(captured["timeout"], tuple) diff --git a/workers/shared/utils/webhook_notify.py b/workers/shared/utils/webhook_notify.py index da41233119..e1e1c0b4eb 100644 --- a/workers/shared/utils/webhook_notify.py +++ b/workers/shared/utils/webhook_notify.py @@ -49,7 +49,12 @@ logger = logging.getLogger(__name__) -_TIMEOUT = 10 +#: Separate budgets, and NOT a total. `requests` applies each to one socket +#: operation, so the wall-clock cost of a delivery is bounded only by how many +#: reads the peer can draw out. The body is never read (see `send_webhook`), +#: which removes the unbounded case; headers remain. +_CONNECT_TIMEOUT = 5 +_READ_TIMEOUT = 10 def send_webhook( @@ -72,14 +77,40 @@ def send_webhook( ): # is_safe_webhook_url already logged the refusal reason and host. return False + # `stream=True` and an explicit close: the status code is the only + # thing this function uses, and without it `requests` reads the + # response BODY before returning. + # + # That mattered because `timeout` is per-read, not total, and this + # runs inline on the `ide_callback` consumer -- concurrency 2 by + # default, shared with Prompt Studio. A receiver answering 200 and + # then trickling a byte every nine seconds, or returning a very large + # body, held one of those two slots for as long as it liked while + # resetting the read timeout on every chunk. Any key holder could + # arrange it cheaply: submit, cancel immediately, and the cancellation + # webhook fires with no LLM spend at all. + # + # With the body never read, the exposure is bounded by `timeout` per + # read of the HEADERS. A server that trickles headers can still extend + # a single delivery, which is why the real fix is to move delivery off + # this consumer onto `notifications_webhook`, as pipeline + # notifications do -- noted on the PR rather than done here, because + # it is a queue change rather than a client one. resp = requests.post( url, data=json.dumps(payload), headers={"Content-Type": "application/json"}, - timeout=_TIMEOUT, + # Explicit (connect, read) rather than a scalar, so the two are + # visible as separate budgets instead of looking like one total. + timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT), allow_redirects=False, + stream=True, ) - return 200 <= resp.status_code < 300 + try: + return 200 <= resp.status_code < 300 + finally: + # Releases the connection without draining the body. + resp.close() except Exception: logger.warning("webhook delivery failed (host=%s)", safe_host(url), exc_info=True) return False From 042670cd631d3ea6df5b77aaed1ddc385a265066 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 14:48:30 +0530 Subject: [PATCH 46/54] UN-4232 [FIX] Three corrections to this session's own review fixes All three found by Greptile reviewing the fixes themselves. **The sweep could free a LIVE slot.** Phase 3's floor moved to `dispatched_at`, but that column records ENQUEUEING, not pickup. Under a queue backlog longer than the grace, a job can be an hour past dispatch with an executor that has only just started -- cancel it and the next sweep frees a slot still in use, letting another submit exceed AGENT_KV_CONCURRENT_LIMIT while the executor bills on. The floor is time since the CANCEL again (`completed_at`), because cancelling is what makes a live executor stop; the ceiling stays on `dispatched_at`, which is the question it actually answers (does Redis still hold the slot, scored from acquisition). Two different columns, so the window still cannot collapse the way it did when both were `completed_at` with equal constants. The first fix got one half right and replaced the other with a different mistake. **The submit-side webhook check broke the e2e webhook scenario.** `test_webhook_delivered_on_completion` posts to `http://host.docker.internal` under `AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE`, which only the WORKER read -- so the new validation 400'd it before a job existed. The backend now honours the same waiver, and the e2e compose override sets it on the backend service as well. Verified both ways: with the waiver set the http URL is accepted, with it unset (production) it is still refused. **A docs claim that was not true.** Section 6.1 said `pages_selected` "is still computed and reported, and the refusal message uses it". Neither: nothing returns it, and the message reports `pages_total`. Now says it is internal only. New test: a job cancelled moments ago keeps its slot, which is the live-slot case. Mutation-checked -- putting the floor back on `dispatched_at` fails it and the kwargs test together. 397 agent_kv tests pass. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_serializers.py | 7 ++++ backend/agent_kv/maintenance.py | 34 ++++++++++++++--- backend/agent_kv/tests/test_sweeps.py | 45 ++++++++++++++++++----- backend/backend/settings/base.py | 24 ++++++++++-- backend/sample.env | 2 +- docs/agent-kv-api.md | 9 +++-- tests/compose/docker-compose.test.yaml | 8 ++++ 7 files changed, 106 insertions(+), 23 deletions(-) diff --git a/backend/agent_kv/execution_serializers.py b/backend/agent_kv/execution_serializers.py index 07f9946fb5..3031a55ce2 100644 --- a/backend/agent_kv/execution_serializers.py +++ b/backend/agent_kv/execution_serializers.py @@ -388,6 +388,13 @@ def validate_webhook_url(self, value): """ if not value: return value + if settings.AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE: + # The same test/dev waiver the delivery sink reads. Both sides + # must honour it or they disagree: with the check only at submit, + # the e2e webhook scenario 400s before a job exists; with it only + # at the sink, this validation is the thing that breaks it. Unset + # in production. + return value reason = webhook_url_refusal(value, allowed_schemes=("https",), resolve=False) if reason is not None: raise serializers.ValidationError( diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index 1aa89ec73b..c72108d964 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -214,13 +214,36 @@ def run_sweep() -> dict: # below `now - SLOT_TTL`). A job submitted seven hours ago and cancelled a # minute ago has a recent `completed_at` and no slot left to release, and # the old bound would have selected it every sweep. + # The floor is time since the CANCEL; the ceiling is the age of the slot. + # Two different clocks, and the first correction here got that half right + # and half wrong. + # + # Measuring the floor from `dispatched_at` was wrong for the reason review + # gave: that column records ENQUEUEING, not when the executor picked the + # job up. A queue backlog of more than the grace means a job can satisfy a + # dispatch-age test while its executor has only just started -- so + # cancelling it would make it eligible on the next sweep and the sweep + # would free a slot that is still in use, letting another submit exceed + # AGENT_KV_CONCURRENT_LIMIT while the executor bills on. + # + # Time since the cancel is the quantity that actually answers "has a live + # executor had its chance to notice and finalize?", because noticing is + # what a cancel triggers. So the floor is `completed_at`. + # + # The ceiling stays on `dispatched_at`: it asks whether Redis still holds + # the slot, and slots are scored by acquisition at submit + # (`check_and_acquire` zadds `now` and trims below `now - SLOT_TTL`). + # Asking that question of the cancel time was the original bug. + # + # Different columns, so the window cannot collapse the way it did when + # both bounds were `completed_at` with equal constants. abandoned_grace = getattr(settings, "AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS", 3600) - abandoned_floor = now - timedelta(seconds=abandoned_grace) + cancelled_floor = now - timedelta(seconds=abandoned_grace) slot_ttl_floor = now - timedelta(seconds=SLOT_TTL_SECONDS) abandoned_cancelled = AgentKVJob.objects.filter( status=JobStatus.CANCELLED, dispatched_at__isnull=False, - dispatched_at__lt=abandoned_floor, + completed_at__lt=cancelled_floor, dispatched_at__gt=slot_ttl_floor, # NEWEST first, the opposite of the other two phases and deliberate. # Releasing a slot does not remove its row from this query -- there is @@ -236,9 +259,10 @@ def run_sweep() -> dict: # `_MAINTENANCE_BATCH_LIMIT` become eligible inside one sweep interval # -- and the TTL remains the backstop for that. # - # Ordered by the same column the window filters on, so "newest" means - # the same thing in both. - ).order_by("-dispatched_at")[:_MAINTENANCE_BATCH_LIMIT] + # Ordered by the CANCEL time, the column the floor filters on: the + # rows at risk of ageing out of the window are the ones cancelled + # longest ago. + ).order_by("-completed_at")[:_MAINTENANCE_BATCH_LIMIT] released = 0 for job in abandoned_cancelled: diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py index 7744e3fab5..c4aded3a9d 100644 --- a/backend/agent_kv/tests/test_sweeps.py +++ b/backend/agent_kv/tests/test_sweeps.py @@ -848,7 +848,10 @@ def test_phase_three_is_bounded_on_both_sides(m_objects, m_release): kwargs = m_objects.filter.call_args_list[2].kwargs assert kwargs["status"] == JobStatus.CANCELLED assert kwargs["dispatched_at__isnull"] is False - assert kwargs["dispatched_at__lt"] == frozen_now - timedelta( + # Floor on the CANCEL time, ceiling on the SLOT's age. Two different + # columns, which is also what stops the window collapsing the way it did + # when both were `completed_at` with equal constants. + assert kwargs["completed_at__lt"] == frozen_now - timedelta( seconds=settings.AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS ) assert kwargs["dispatched_at__gt"] == frozen_now - timedelta( @@ -883,7 +886,7 @@ def test_phase_three_takes_the_newest_eligible_rows_first(): m_objects.filter.side_effect = [phase1, phase2_status, phase3] maintenance.run_sweep() - phase3.order_by.assert_called_once_with("-dispatched_at") + phase3.order_by.assert_called_once_with("-completed_at") # --------------------------------------------------------------------------- @@ -1057,7 +1060,7 @@ def _sweep(self): def test_an_abandoned_cancelled_job_has_its_slot_released(self): """The whole point of the phase, and it had never once happened.""" - job = self._cancelled(dispatched_ago=2 * 3600) + job = self._cancelled(dispatched_ago=4 * 3600, completed_ago=2 * 3600) result, m_release = self._sweep() @@ -1065,8 +1068,8 @@ def test_an_abandoned_cancelled_job_has_its_slot_released(self): m_release.assert_called_once_with(str(self.org.id), str(job.id)) def test_a_job_dispatched_moments_ago_is_left_alone(self): - """The floor still protects a live executor.""" - self._cancelled(dispatched_ago=60) + """A job dispatched and cancelled moments ago keeps its slot.""" + self._cancelled(dispatched_ago=60, completed_ago=30) result, m_release = self._sweep() @@ -1077,25 +1080,47 @@ def test_a_job_whose_slot_redis_has_already_dropped_is_left_alone(self): """Past the slot TTL there is nothing to release, so selecting it would mean re-releasing the same rows on every sweep forever. """ - self._cancelled(dispatched_ago=SLOT_TTL_SECONDS + 3600) + self._cancelled( + dispatched_ago=SLOT_TTL_SECONDS + 3600, completed_ago=2 * 3600 + ) result, m_release = self._sweep() assert result["released"] == 0 - def test_the_window_is_measured_from_dispatch_not_from_the_cancel(self): + def test_the_slot_age_is_measured_from_dispatch_not_from_the_cancel(self): """Submitted long ago, cancelled a minute ago: the slot is gone. - The old ceiling read `completed_at`, so this row looked eligible -- - recent cancel, inside the TTL -- when its slot had expired hours + The original ceiling read `completed_at`, so this row looked eligible + -- recent cancel, inside the TTL -- when its slot had expired hours earlier. It would have been re-selected every sweep. """ - self._cancelled(dispatched_ago=SLOT_TTL_SECONDS + 3600, completed_ago=60) + self._cancelled( + dispatched_ago=SLOT_TTL_SECONDS + 3600, completed_ago=2 * 3600 + ) result, _ = self._sweep() assert result["released"] == 0 + def test_a_job_cancelled_moments_ago_keeps_its_slot(self): + """The floor is time since the CANCEL, not since dispatch. + + `dispatched_at` records ENQUEUEING. Under a queue backlog longer than + the grace, a job can be an hour past dispatch with an executor that + has only just started -- so a dispatch-age floor would free a slot + that is still in use, and another submit could then exceed + AGENT_KV_CONCURRENT_LIMIT while the executor bills on. Cancelling is + what makes a live executor stop, so the grace has to run from there. + Reported by Greptile on #2317, against this phase's first fix. + """ + self._cancelled(dispatched_ago=4 * 3600, completed_ago=30) + + result, m_release = self._sweep() + + assert result["released"] == 0 + assert not m_release.called + def test_the_grace_is_its_own_setting_and_shorter_than_the_slot_ttl(self): """The invariant that keeps the window non-empty. If these ever meet again, phase 3 silently stops working and every test above still diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index 0ac6a12d09..93346d7e70 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -268,6 +268,18 @@ def validate_agent_kv_path_prefix(raw, path_prefix, deployment_prefix): or "unstract/agent_kv" ) # same normalisation as the cloud executor's reader (whitespace, edge slashes) AGENT_KV_MAX_FILE_SIZE_MB = int(os.environ.get("AGENT_KV_MAX_FILE_SIZE_MB", 50)) + +#: Test/dev only: waive the https-and-public-host rule on `webhook_url` at +#: SUBMIT, matching the waiver `workers/ide_callback/agent_kv_tasks.py` already +#: reads at delivery. One switch, honoured on both sides. +#: +#: Needed because submit-side validation and the delivery guard have to agree: +#: with the check only at the sink, the e2e webhook scenario could post to a +#: receiver on the compose host; with it only at submit, that scenario 400s +#: before a job exists. Unset in production, where both guards stay mandatory. +AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE = os.environ.get( + "AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE", "" +).lower() in ("1", "true", "yes") AGENT_KV_MAX_PAGES = int(os.environ.get("AGENT_KV_MAX_PAGES", 100)) AGENT_KV_MAX_CALCULATIONS_BYTES = int( os.environ.get("AGENT_KV_MAX_CALCULATIONS_BYTES", 20_000) @@ -301,9 +313,15 @@ def validate_agent_kv_path_prefix(raw, path_prefix, deployment_prefix): #: and empty for ANY grace >= TTL. The phase could never select a row, and #: `released` was always 0. #: -#: One hour rather than six because the job is already CANCELLED: a live -#: executor learns of that at its next stage report and stops, so an hour of -#: silence after dispatch is already strong evidence that nothing is coming. +#: Measured from the CANCEL, not from dispatch. `dispatched_at` records +#: enqueueing, so under a queue backlog a job can be an hour past dispatch +#: with an executor that has only just started -- releasing then would free a +#: slot still in use. Cancelling is what makes a live executor stop, so the +#: grace runs from there. +#: +#: One hour rather than six because a live executor learns of the cancel at +#: its next stage report, so an hour of silence after it is already strong +#: evidence that nothing is coming. #: Waiting the full six hours is indistinguishable from waiting for Redis to #: expire the slot on its own, which is the behaviour this phase exists to #: improve on. diff --git a/backend/sample.env b/backend/sample.env index e9175348df..902e220684 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -200,7 +200,7 @@ AGENT_KV_KEY_RATE_LIMIT_PER_MINUTE=60 # Per-key request rate limit AGENT_KV_LIMITER_FAIL_OPEN=false # true = both limiters above allow the request when Redis is down (default: refuse with 429) AGENT_KV_SWEEP_GRACE_SECONDS=3600 # Age before a never-dispatched PENDING job is swept AGENT_KV_STUCK_JOB_GRACE_SECONDS=21600 # Age (from dispatch) before a stuck DISPATCHED/RUNNING job is force-failed (6h) -AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS=3600 # Age (from dispatch) before a CANCELLED job's executor is presumed dead and its slot released; must be < the 6h slot TTL +AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS=3600 # Age (from CANCELLATION) before a cancelled job's executor is presumed dead and its slot released; must be < the 6h slot TTL AGENT_KV_CALCULATIONS_ENABLED=false # Gates the submit `calculations` field; engine can't run it yet AGENT_KV_STRUCTURED_OUTPUT_ENABLED=false # Gates the submit `structured_output` field; engine can't run it yet diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 039e623954..06b61f658d 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -344,9 +344,10 @@ Not exposed (D6): model choice, challenger model, `parallel_pages`, thinking bud requests the worker then fails — after the 202, after the concurrency slot and after the upload. - `pages_selected` is still computed and reported; it is the honest count of - what will be EXTRACTED, and the refusal message uses it. If OCR ever honours - the range, this is the line to revisit. + `pages_selected` is computed internally but is **not** returned anywhere: + the status document and the page-limit error both report `pages_total`. If + OCR ever honours the range, this is the line to revisit — and the count + the cap checks is what would change. - Images (`.png .jpg .jpeg .tiff`) are **not** accepted, and no longer count as 1 page. The engine treats only `.pdf/.xlsx/.xls` as a document and `ImageLoader.load_pages` has no call site, so an accepted image returned @@ -1423,7 +1424,7 @@ with the default shown: |---|---|---| | `AGENT_KV_PATH_PREFIX` | `agent-kv` | Top-level public URL prefix (whitelisted past tenant middleware). | | `AGENT_KV_MAX_FILE_SIZE_MB` | `50` | Submit-time file size cap. | -| `AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS` | `3600` | How long after DISPATCH a cancelled job's executor is presumed dead, so the sweep's phase 3 may release the concurrency slot it still holds. Must stay **below** `rate_limiter.SLOT_TTL_SECONDS` (6 h) or the window is empty and the phase silently releases nothing. | +| `AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS` | `3600` | How long after CANCELLATION a cancelled job's executor is presumed dead, so the sweep's phase 3 may release the concurrency slot it still holds. Must stay **below** `rate_limiter.SLOT_TTL_SECONDS` (6 h) or the window is empty and the phase silently releases nothing. | | `AGENT_KV_MAX_PAGES` | `100` | Pre-OCR cap on the pages of the DOCUMENT for PDFs — checked against `pages_total`, **not** against the `page_start`/`page_end` range (§6.1), because the whole file is OCR'd and billed whatever the range. The cloud worker refuses on the same quantity. Excel has no pre-OCR page concept, so it takes the engine's post-OCR virtual-page cap instead; images are not accepted. | | `AGENT_KV_MAX_CALCULATIONS_BYTES` | `20000` | Byte cap on the optional `calculations` field. | | `AGENT_KV_MAX_SCHEMA_BYTES` | `262144` | Byte cap on the raw `keys` JSON document (256 KiB). | diff --git a/tests/compose/docker-compose.test.yaml b/tests/compose/docker-compose.test.yaml index 7dcce8dd00..2cd00ddcc8 100644 --- a/tests/compose/docker-compose.test.yaml +++ b/tests/compose/docker-compose.test.yaml @@ -86,6 +86,14 @@ services: environment: - AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=${AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE:-} + # The BACKEND needs the same waiver, because the https-and-public-host rule + # is now applied at SUBMIT as well as at delivery. Without it here the e2e + # webhook scenario is refused with a 400 before a job is ever created, and + # the worker-side waiver above never gets a chance to matter. + backend: + environment: + - AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=${AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE:-} + # The `ui` Playwright group drives a browser at the frontend's OWN origin, and # the app derives every API URL from window.location.origin. The production # nginx config serves only static files plus an SPA fallback, so `/api/v1/*` From afbb199c197346e50f138229213cfee5c3972ee2 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 14:57:55 +0530 Subject: [PATCH 47/54] UN-4232 [FIX] Merge the webhook waiver into the existing backend compose block I added the backend's `AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE` as a SECOND `backend:` key, in a file that already defines one at line 5. Docker Compose's loader rejects a duplicate mapping key outright, so the e2e tier died at `compose up` in 0.4s, before a single container started: line 93: mapping key "backend" already defined at line 5 `check-yaml` passed it, both when I broke it and when I fixed it: pre-commit's hook parses with `yaml.safe_load`, which resolves a duplicate key to the last one silently rather than rejecting it. So the two loaders disagree, and the strict one is the one that runs in CI. The variable is now a line in the original block, which also carries the `image:` pin and MAX_PARALLEL_FILE_BATCHES, AGENT_KV_MAX_PAGES, AGENT_KV_STORAGE_DIR_PREFIX and AGENT_KV_CALCULATIONS_ENABLED. Verified by loading the file with a duplicate-key-strict loader: 8 services, backend keeps all 5 env vars and the waiver. Co-Authored-By: Claude Opus 5 --- tests/compose/docker-compose.test.yaml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/compose/docker-compose.test.yaml b/tests/compose/docker-compose.test.yaml index 2cd00ddcc8..f56528ba7d 100644 --- a/tests/compose/docker-compose.test.yaml +++ b/tests/compose/docker-compose.test.yaml @@ -22,6 +22,13 @@ services: # Flips on the Agent-KV calc e2e lane (Task 12); unset/empty (the # default) keeps it off so other lanes are unaffected. - AGENT_KV_CALCULATIONS_ENABLED=${AGENT_KV_CALCULATIONS_ENABLED:-} + # The webhook SSRF waiver, which the backend needs as well as + # worker-pg-ide-callback below: the https-and-public-host rule is + # applied at SUBMIT as well as at delivery, so without it here the + # webhook e2e scenario is refused with a 400 before a job exists and + # the worker-side waiver never gets a chance to matter. Both services + # read the same variable; unset (the default) keeps both guards on. + - AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=${AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE:-} platform-service: image: unstract/platform-service:${UNSTRACT_TEST_VERSION:-latest} @@ -86,14 +93,6 @@ services: environment: - AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=${AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE:-} - # The BACKEND needs the same waiver, because the https-and-public-host rule - # is now applied at SUBMIT as well as at delivery. Without it here the e2e - # webhook scenario is refused with a 400 before a job is ever created, and - # the worker-side waiver above never gets a chance to matter. - backend: - environment: - - AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE=${AGENT_KV_WEBHOOK_INSECURE_ALLOW_HTTP_PRIVATE:-} - # The `ui` Playwright group drives a browser at the frontend's OWN origin, and # the app derives every API URL from window.location.origin. The production # nginx config serves only static files plus an SPA fallback, so `/api/v1/*` From 94c008475fce33348130f5b15fe9e09b953552bf Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 15:09:23 +0530 Subject: [PATCH 48/54] UN-4232 [MISC] Use findByText for the keys-page title assertion SonarCloud's last open issue on this PR (MINOR code smell): `waitFor` + `getByText` where Testing Library offers `findByText`, which is the same retry loop with the query built in. `waitFor` is kept in the other test, which waits on a mock call rather than on the DOM, so the import stays. Checked the assertion still bites: mutating the expected title fails the test, so this is not a query that silently finds nothing. Co-Authored-By: Claude Opus 5 --- .../components/settings/agent-kv-keys/AgentKvKeys.test.jsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.test.jsx b/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.test.jsx index cad3b84c70..a58f396542 100644 --- a/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.test.jsx +++ b/frontend/src/components/settings/agent-kv-keys/AgentKvKeys.test.jsx @@ -55,8 +55,6 @@ describe("AgentKvKeys", () => { it("is titled for the API the key is used against", async () => { render(); - await waitFor(() => - expect(screen.getByText("Agent-KV API Keys")).toBeInTheDocument(), - ); + expect(await screen.findByText("Agent-KV API Keys")).toBeInTheDocument(); }); }); From b495e025b855eec8848dee0064794d27120ed21d Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 15:23:57 +0530 Subject: [PATCH 49/54] UN-4232 [FIX] Two e2e scenarios that fail on correct behaviour Both are skipped in CI (no adapter env), so neither has ever run. Both would have gone red the first time someone configured the lane properly -- blaming the code for behaviour that is right. **Excel scenario asserted `success` in the wrong place.** It read it off `table_result_body()`, which is `extractors.table` -- the engine's own record. The engine writes `tables`, `page_count`, `table_pages`, `headers`, `row_count`, `detected` and `reason` there; the only nested `success` belongs to `code_execution` and describes that step. So `.get("success")` was None and a SUCCESSFUL extraction failed the assert. Now read from the top level, like the three other `success` asserts in the file, including the sync-wait scenario whose comment already states the rule. **Page-cap scenario ran on a false premise at the boundary.** The serializer refuses `pages_total > cap`, so a cap EQUAL to the page count accepts the document -- correctly. The guard skipped only on `cap > fixture_pages`, leaving `cap == 2` running a two-page fixture and expecting a 400 that should never come. Now skips on `>=`; checked across caps 0-199 that the new guard never runs the scenario when the serializer would accept. The comment also cited `invoice.pdf` while the body submits `rent_roll.pdf` (both 2 pages, so the number was right by luck). Also corrected the comment in `execution_views_result.py` that claimed "each extractor keeps its own `success` inside its own block". No extractor does. That sentence read as a contract, is what the Excel scenario was written against, and nearly got this finding dismissed as invalid. The change is comment-only -- verified by comparing the parsed AST before and after, which is identical. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_views_result.py | 14 +++++++++++-- tests/e2e/agent_kv/test_agent_kv_e2e.py | 23 +++++++++++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/backend/agent_kv/execution_views_result.py b/backend/agent_kv/execution_views_result.py index f21cc531d9..8c067e31ee 100644 --- a/backend/agent_kv/execution_views_result.py +++ b/backend/agent_kv/execution_views_result.py @@ -45,8 +45,18 @@ def result_payload(job) -> dict: # `success`/`status` stay at TOP level on every terminal payload -- # completed, failed and cancelled alike -- so a client branches on # one key rather than having to know that only the failure shapes - # carry it. They describe the JOB; each extractor keeps its own - # `success` inside its own block. + # carry it. This is the ONLY `success` describing the job. + # + # This used to add "each extractor keeps its own `success` inside + # its own block". No extractor does: `table` writes `tables`, + # `page_count`, `table_pages`, `headers`, `row_count`, `detected` + # and `reason`, and the sole nested `success` belongs to + # `code_execution`, describing that step rather than the + # extraction. The claim read as a contract and cost real time -- + # an e2e scenario asserted it and would have failed a successful + # Excel run. A per-extractor `success` can be added if a + # multi-extractor job ever needs partial failure; until then do + # not document one. "success": True, "status": JobStatus.COMPLETED.lower(), "extractors": {job.extractor: read_result(job.result_ref)}, diff --git a/tests/e2e/agent_kv/test_agent_kv_e2e.py b/tests/e2e/agent_kv/test_agent_kv_e2e.py index bbbc937820..b13000238b 100644 --- a/tests/e2e/agent_kv/test_agent_kv_e2e.py +++ b/tests/e2e/agent_kv/test_agent_kv_e2e.py @@ -528,13 +528,18 @@ def test_page_cap_rejects_oversized_document( ``AGENT_KV_MAX_PAGES`` skip below usually fires first, so it would only have gone red on a lane that configured the cap. """ - fixture_pages = 2 # fixtures/invoice.pdf + fixture_pages = 2 # fixtures/rent_roll.pdf, the document submitted below cap_raw = os.environ.get("AGENT_KV_MAX_PAGES") - if cap_raw is None or int(cap_raw) > fixture_pages: + # The serializer refuses `pages_total > cap`, so a cap EQUAL to the page + # count accepts the document. Skipping only on `>` left `cap == 2` running + # a scenario whose premise is false: the submit is correctly accepted and + # the 400 assert reports a defect that is not there. + if cap_raw is None or int(cap_raw) >= fixture_pages: pytest.skip( - f"AGENT_KV_MAX_PAGES={cap_raw!r} exceeds the fixture's " - f"{fixture_pages} pages; set AGENT_KV_MAX_PAGES<={fixture_pages} " - "on the backend service to exercise the cap" + f"AGENT_KV_MAX_PAGES={cap_raw!r} does not refuse the fixture's " + f"{fixture_pages} pages (the cap refuses pages_total > cap, so " + f"equality passes); set AGENT_KV_MAX_PAGES<{fixture_pages} on the " + "backend service to exercise the cap" ) resp = submit_raw(agent_kv_key, _RENT_ROLL_BYTES, "rent_roll.pdf", table_keys()) assert resp.status_code == 400, resp.text @@ -667,8 +672,12 @@ def test_excel_submit_extracts( ) doc = poll(agent_kv_key, job_id) assert doc["status"] == "completed", doc - body = table_result_body(result(agent_kv_key, job_id)) - assert body.get("success") is True, body + resp = result(agent_kv_key, job_id) + # `success` is top level, NOT inside the extractor's own record -- the + # same shape the sync-wait scenario above asserts (spec §7.3). Reading it + # off `table_result_body()` found None and failed a successful run. + assert resp.json().get("success") is True, resp.json() + body = table_result_body(resp) assert isinstance(body["tables"], list), body assert "page_count" in body, body From 4e05c0015042d64f90e69ddc55c26d34f27271e2 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 15:56:21 +0530 Subject: [PATCH 50/54] UN-4232 [FIX] The backend owns the terminal webhook, so a lost response cannot lose it Review finding on #2317: the terminal webhook could be lost on a finalize replay. Finalize commits, the HTTP response is lost (timeout or 5xx), `_make_request` retries or the PG consumer redelivers, the replay finds an already-terminal row and returns `finalized: False`, and `_maybe_webhook` returns silently. The caller is never notified and nothing records the miss. It could not be fixed where it was. From inside the callback worker the "already notified" and "never notified" cases are INDISTINGUISHABLE -- nothing records whether the webhook fired -- so the declined send was the correct call from there, and a log line would have fired on the ordinary duplicate-callback path too, which is how real signals get ignored. So the notification moves to the backend, queued by whichever request won the terminal guard. That is not a new mechanism: it is exactly what the cancel path already does via `dispatch_cancelled_webhook`, and the two are mutually exclusive, so a job still gets exactly one terminal notification. Tying the enqueue to the guard rather than to an HTTP reply is the fix -- losing the response no longer loses the notification, and the queued task brings a retry budget and a visible failure, which also closes the durable- delivery gap tracked as 2.10. I had argued this belonged in a follow-up because it "crosses the repo boundary". That was wrong: `backend/` and `workers/` are both in this repo. And because `backend/agent_kv/` does not exist on main at all, the whole feature ships as one unit -- there is no deployed old worker to stay compatible with, so no flag or shim is needed. - `_enqueue_webhook` is shared by both paths so they cannot drift; the status rides on the message, so the worker announces what the backend actually terminalized the row to instead of guessing. - `agent_kv_finalized` joins `agent_kv_cancelled`, sharing one delivery helper -- the hard part, telling a retryable failure from a permanently undeliverable URL, is identical and belongs in one place. - Registered in the task-route registry. A task name missing there routes to a queue with no consumer, which accepts work and drains nothing. - `_maybe_webhook` keeps only the `result_available` follow-up, which is not a terminal transition and so has no guard to hang on. Tests: the three new view-level tests were mutation-checked -- removing the enqueue fails all three. The fourth (a replay queues nothing) passes either way by design: it guards against trading the lost webhook for a duplicate one. Four existing tests asserted the old inline send and were rewritten rather than deleted; one of them, the "delivered webhook is quiet" case, would have passed VACUOUSLY afterwards (no send means no error log), so it is now a real assertion against the task. 342 backend and 82 worker tests pass. The 64 DB-backed errors in this worktree are a local Postgres credential mismatch, confirmed identical with these changes stashed. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/dispatch.py | 91 +++++++-- backend/agent_kv/internal_views.py | 12 ++ backend/agent_kv/tests/test_dispatch.py | 94 +++++++++ backend/agent_kv/tests/test_internal_views.py | 112 +++++++++++ docs/agent-kv-api.md | 26 ++- workers/ide_callback/agent_kv_tasks.py | 188 ++++++++++-------- .../tests/test_agent_kv_callbacks.py | 143 ++++++++----- .../shared/infrastructure/config/registry.py | 13 +- 8 files changed, 515 insertions(+), 164 deletions(-) diff --git a/backend/agent_kv/dispatch.py b/backend/agent_kv/dispatch.py index e61916475b..3e1e9cbd6b 100644 --- a/backend/agent_kv/dispatch.py +++ b/backend/agent_kv/dispatch.py @@ -20,6 +20,74 @@ class DispatchError(Exception): """Enqueue failed; the caller terminalizes the job (spec §5.3).""" +def _enqueue_webhook(task_name: str, job, status: str) -> None: + """Queue a terminal-webhook task for ``job``, best-effort. + + Shared by the cancel and finalize paths so the two cannot drift: both + terminal notifications are OWNED BY THE BACKEND and queued by whichever + request won the terminal guard. Delivery, retries and the permanently- + undeliverable case are the worker's problem. + + Best-effort by design, as the cancel path has always been: the job is + already terminal and the caller already has their response, so failing + that request because a notification could not be QUEUED would be the + wrong trade. Logs and returns. + """ + if not job.webhook_url: + return + try: + from pg_queue.producer import enqueue_task + + enqueue_task( + task_name=task_name, + queue=CALLBACK_QUEUE, + kwargs={ + "callback_kwargs": { + "job_id": str(job.id), + "webhook_url": job.webhook_url, + "status": status, + } + }, + org_id=str(job.organization_id), + ) + except Exception: + logger.exception( + "agent-kv: could not queue the %s webhook for job %s; the job IS " + "%s, only the notification was lost", + status, + job.id, + status, + ) + + +def dispatch_terminal_webhook(job, status: str) -> None: + """Queue the terminal webhook for a job finalize just terminalized. + + Call ONLY when ``mark_terminal`` actually won, which is what makes this + mutually exclusive with ``dispatch_cancelled_webhook``: a finalize that + won means no cancel did, and vice versa. Exactly one path notifies. + + **Why the backend owns this rather than the callback worker.** The worker + used to send it inline after finalize returned. If that HTTP response was + lost -- a timeout, or a 5xx -- the worker retried, the replay found an + already-terminal row and reported ``finalized: False``, and the worker + correctly declined to send, because from out there a non-fresh finalize is + indistinguishable from the ordinary duplicate-callback case where the + webhook already went out. The caller was never told and nothing recorded + the miss. Queuing here instead means the notification is committed by the + one request that won the guard, so losing the response to that request no + longer loses the notification: the queued task has its own retry budget + and a visible failure. + + The residual window is a process death between the guard-winning UPDATE + and this enqueue, which is microseconds rather than a whole HTTP round + trip. Closing that too would mean enqueuing inside ``mark_terminal``'s + transaction; it is not done here because the enqueue is deliberately + best-effort and must not be able to roll back a terminal state. + """ + _enqueue_webhook("agent_kv_finalized", job, status) + + def dispatch_cancelled_webhook(job) -> None: """Queue the terminal webhook for a job the API just cancelled. @@ -47,28 +115,7 @@ def dispatch_cancelled_webhook(job) -> None: QUEUED would be the wrong trade, so this logs and returns rather than raising. Delivery itself is the worker's problem. """ - if not job.webhook_url: - return - try: - from pg_queue.producer import enqueue_task - - enqueue_task( - task_name="agent_kv_cancelled", - queue=CALLBACK_QUEUE, - kwargs={ - "callback_kwargs": { - "job_id": str(job.id), - "webhook_url": job.webhook_url, - } - }, - org_id=str(job.organization_id), - ) - except Exception: - logger.exception( - "agent-kv: could not queue the cancellation webhook for job %s; " - "the job IS cancelled, only the notification was lost", - job.id, - ) + _enqueue_webhook("agent_kv_cancelled", job, "cancelled") def _dispatcher(): diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index 101c986b51..b6b56ce3f1 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -15,6 +15,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from agent_kv.dispatch import dispatch_terminal_webhook from agent_kv.maintenance import run_sweep, run_ttl_cleanup from agent_kv.models import AgentKVJob, JobStatus from agent_kv.rate_limiter import AgentKVConcurrencyLimiter @@ -419,6 +420,17 @@ def post(self, request, *args, job_id=None, **kwargs): handler = self._complete if success else self._fail finalized, result_attached = handler(job, job_id, org_id, body) if finalized: + # Queue the terminal webhook HERE, on the request that won + # the guard -- not in the callback worker after this + # response gets back to it. A lost response (timeout, 5xx) + # used to lose the notification outright: the worker + # retried, the replay reported `finalized: False`, and the + # worker declined to send because a non-fresh finalize is + # indistinguishable from the ordinary duplicate callback + # whose webhook already went out. Mirrors what the cancel + # path has always done, so exactly one of the two owns the + # notification for any job. + dispatch_terminal_webhook(job, job.status.lower()) self._drop_staged_input(job, job_id, org_id) elif job is not None and success and job.status == JobStatus.CANCELLED: # The job was already CANCELLED when this request read it, and diff --git a/backend/agent_kv/tests/test_dispatch.py b/backend/agent_kv/tests/test_dispatch.py index c63854ae6c..38d4797059 100644 --- a/backend/agent_kv/tests/test_dispatch.py +++ b/backend/agent_kv/tests/test_dispatch.py @@ -383,3 +383,97 @@ def test_the_terminal_check_reads_the_row_rather_than_the_in_memory_copy( dispatch.dispatch_job(job, extractor=TABLE_EXTRACTOR_NAME, schema={}, options={}) assert _filtered_with(m_objects, id=job.id, status__in=list(AgentKVJob.TERMINAL)) + + +# --------------------------------------------------------------------------- +# Terminal-webhook enqueue (both paths share one helper) +# --------------------------------------------------------------------------- + + +class _FakeJob: + """Just the three attributes the enqueue reads.""" + + def __init__(self, webhook_url="https://hook.example/x"): + self.id = uuid.uuid4() + self.organization_id = uuid.uuid4() + self.webhook_url = webhook_url + + +def _patch_producer(monkeypatch): + """Stand in for `pg_queue.producer`, which `_enqueue_webhook` imports + lazily inside the call.""" + import sys + import types + + calls = [] + mod = types.ModuleType("pg_queue.producer") + mod.enqueue_task = lambda **kw: calls.append(kw) + pkg = sys.modules.get("pg_queue") or types.ModuleType("pg_queue") + monkeypatch.setitem(sys.modules, "pg_queue", pkg) + monkeypatch.setitem(sys.modules, "pg_queue.producer", mod) + return calls + + +def test_finalize_enqueues_the_terminal_webhook_with_the_backend_s_status(monkeypatch): + """The status rides on the message, so the worker announces what the + backend actually terminalized the row to rather than guessing.""" + calls = _patch_producer(monkeypatch) + job = _FakeJob() + + dispatch.dispatch_terminal_webhook(job, "failed") + + assert len(calls) == 1 + assert calls[0]["task_name"] == "agent_kv_finalized" + assert calls[0]["queue"] == dispatch.CALLBACK_QUEUE + assert calls[0]["kwargs"]["callback_kwargs"] == { + "job_id": str(job.id), + "webhook_url": "https://hook.example/x", + "status": "failed", + } + assert calls[0]["org_id"] == str(job.organization_id) + + +def test_cancel_still_enqueues_its_own_task_through_the_same_helper(monkeypatch): + """Sharing the helper must not have collapsed the two task names into + one: they are separate consumers and separate notifications.""" + calls = _patch_producer(monkeypatch) + job = _FakeJob() + + dispatch.dispatch_cancelled_webhook(job) + + assert len(calls) == 1 + assert calls[0]["task_name"] == "agent_kv_cancelled" + assert calls[0]["kwargs"]["callback_kwargs"]["status"] == "cancelled" + + +def test_no_webhook_url_enqueues_nothing(monkeypatch): + calls = _patch_producer(monkeypatch) + + dispatch.dispatch_terminal_webhook(_FakeJob(webhook_url=""), "completed") + dispatch.dispatch_cancelled_webhook(_FakeJob(webhook_url="")) + + assert calls == [] + + +def test_a_failed_enqueue_is_logged_not_raised(monkeypatch, caplog): + """Best-effort by design: the job is already terminal and the caller + already has their response, so failing that request because a + NOTIFICATION could not be queued would be the wrong trade.""" + import logging + import sys + import types + + mod = types.ModuleType("pg_queue.producer") + + def _boom(**kw): + raise RuntimeError("queue down") + + mod.enqueue_task = _boom + pkg = sys.modules.get("pg_queue") or types.ModuleType("pg_queue") + monkeypatch.setitem(sys.modules, "pg_queue", pkg) + monkeypatch.setitem(sys.modules, "pg_queue.producer", mod) + + with caplog.at_level(logging.ERROR, logger="agent_kv.dispatch"): + dispatch.dispatch_terminal_webhook(_FakeJob(), "completed") + + assert "could not queue the completed webhook" in caplog.text diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py index 784770e462..151eba292d 100644 --- a/backend/agent_kv/tests/test_internal_views.py +++ b/backend/agent_kv/tests/test_internal_views.py @@ -1039,3 +1039,115 @@ def test_an_overlong_stage_name_is_rejected(m_objects): assert resp.status_code == 400 assert "too long" in str(resp.data) + + +# --------------------------------------------------------------------------- +# The terminal webhook is queued by the request that WON the guard +# --------------------------------------------------------------------------- +# Regression cover for the finalize-replay notification loss reported on +# #2317. The worker used to send the terminal webhook inline, after this view +# answered. If that RESPONSE was lost -- a timeout, or a 5xx -- the worker +# retried, the replay found an already-terminal row and was told +# `finalized: False`, and the worker declined to send: from out there a +# non-fresh finalize is indistinguishable from the ordinary duplicate +# callback whose webhook already went out. The caller was never notified and +# nothing recorded the miss. +# +# Tying the enqueue to the guard rather than to the response is the fix, so +# what these pin is: exactly one enqueue, on the winning request, whatever +# happens to the reply. + + +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "dispatch_terminal_webhook") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(iv, "write_result", return_value="ref") +@mock.patch.object(AgentKVJob, "objects") +def test_a_won_finalize_queues_the_terminal_webhook( + m_jobs, m_write, m_mark, m_dispatch, m_release +): + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + + iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {}}), + job_id=uuid.uuid4(), + ) + + m_dispatch.assert_called_once_with(job, "completed") + + +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "dispatch_terminal_webhook") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(AgentKVJob, "objects") +def test_a_won_failure_finalize_queues_failed_not_completed( + m_jobs, m_mark, m_dispatch, m_release +): + """The status is read off the row the view just terminalized, so the + caller is told `failed` rather than a hardcoded `completed`.""" + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + + iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": False, "error": "boom"}), + job_id=uuid.uuid4(), + ) + + m_dispatch.assert_called_once_with(job, "failed") + + +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "dispatch_terminal_webhook") +@mock.patch.object(iv, "delete_result_file") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=False) +@mock.patch.object(iv, "write_result", return_value="ref") +@mock.patch.object(AgentKVJob, "objects") +def test_a_replay_that_loses_the_guard_queues_nothing( + m_jobs, m_write, m_mark, m_del, m_dispatch, m_release +): + """The retry after a lost response must NOT queue a second notification. + + This is the other half of the fix: the first (winning) call already + queued it, so re-queuing here would double-notify the caller -- trading + the old lost-webhook bug for a duplicate-webhook one. + """ + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + + iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {}}), + job_id=uuid.uuid4(), + ) + + m_dispatch.assert_not_called() + + +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "dispatch_terminal_webhook") +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(iv, "write_result", return_value="ref") +@mock.patch.object(AgentKVJob, "objects") +def test_the_notification_does_not_depend_on_the_response_being_delivered( + m_jobs, m_write, m_mark, m_dispatch, m_release +): + """The actual reported failure, expressed directly. + + The enqueue must already have happened by the time this view produces a + response, so a response that never reaches the worker cannot take the + notification with it. Asserting ordering rather than just "it was called" + is what distinguishes the fix from the bug: the old code called out only + AFTER the reply had made it back to the caller. + """ + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + queued_before_response = [] + m_dispatch.side_effect = lambda *a, **k: queued_before_response.append(True) + + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {}}), + job_id=uuid.uuid4(), + ) + + assert queued_before_response == [True] + assert resp.status_code == 200 diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 06b61f658d..81df46d496 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -856,11 +856,23 @@ curl -X DELETE https://api.unstract.example/agent-kv/5b6e9b0a-.../ \ `webhook_url` (if given at submit) receives one terminal-state POST: `{"job_id": "...", "status": "completed"|"failed"|"cancelled"}` — a fixed payload -shape, no result content (spec §6.7, §7.1). Delivered from the `ide_callback` worker -(`workers/ide_callback/agent_kv_tasks.py::_maybe_webhook`) only on a *fresh* finalize -(a duplicate/late finalize for an already-terminal job does not re-fire it, with the -one exception below), or from `agent_kv_cancelled` when a cancel won the race -instead. +shape, no result content (spec §6.7, §7.1). + +**The backend owns the terminal notification**, on both paths, and queues it from +whichever request won the terminal guard: `agent_kv_finalized` when a finalize won, +`agent_kv_cancelled` when a cancel did. The two are mutually exclusive, so a job +gets exactly one — and because the enqueue is tied to the guard rather than to an +HTTP reply, losing a response cannot lose the notification. Delivery, retries and +the permanently-undeliverable case belong to the `ide_callback` worker. + +> This changed. The finalize notification used to be sent inline by the callback +> worker (`_maybe_webhook`) right after finalize answered. If that *response* was +> lost — a timeout, or a 5xx — the worker retried, the replay found an +> already-terminal row and was told `finalized: false`, and the worker declined to +> send. That was the correct call from where it stood: a non-fresh finalize is +> indistinguishable from the ordinary duplicate callback whose webhook already went +> out. The effect was that the caller was never notified and nothing recorded the +> miss. **There is exactly one case of a second POST**, and it is additive: @@ -879,7 +891,9 @@ simply sees a repeat of the cancellation. Nothing orders the two POSTs. The cancellation one is queued by the backend the moment its cancel wins and delivered by a worker; the follow-up is sent inline by -the finalize callback. A receiver can therefore see the follow-up first and then the +the finalize callback, which is the one notification that is still sent from there +— it is not a terminal transition, so there is no guard for the backend to hang it +on. A receiver can therefore see the follow-up first and then the plain `cancelled` — treat `result_available` as sticky, not as the state of the world at delivery time. The result's readability does not depend on either POST landing: `GET result` is the authority, and this notification only saves a poll. diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index 81699ea1e2..5846754d43 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -223,43 +223,30 @@ def agent_kv_error( raise -@worker_task( - bind=True, - name="agent_kv_cancelled", - autoretry_for=(Exception,), - max_retries=3, - retry_backoff=True, - retry_backoff_max=300, - retry_jitter=True, -) -def agent_kv_cancelled( - self, - callback_kwargs: dict[str, Any] | None = None, -) -> dict[str, Any] | None: - """Fire the terminal webhook for a job cancelled by the API. +def _deliver_terminal_webhook(task, callback_kwargs, default_status: str) -> dict | None: + """Deliver one terminal webhook, with the retry policy both tasks share. - Cancellation does not go through finalize: ``JobCancelView`` (and DELETE on - a non-terminal job) terminalizes the row directly, releases the slot and - returns. So nothing in the finalize path ever fired a webhook for it, and a - late executor callback cannot either -- it loses the terminal guard and - ``_maybe_webhook`` correctly declines a non-fresh finalize. The caller who - supplied ``webhook_url`` was simply never told, despite docs §8 promising - delivery on terminal states. - - Enqueued by the backend ONLY when its guarded cancel actually won, which is - what keeps this from double-notifying: if the cancel lost to a finalize, the - backend does not enqueue and the finalize callback sends instead. Exactly - one of the two paths owns the notification for any given job. + Extracted so `agent_kv_cancelled` and `agent_kv_finalized` cannot drift: + they differ only in which terminal state they announce, and the hard part + -- telling a retryable failure from a permanently undeliverable URL -- is + identical and worth having in one place. """ cb = callback_kwargs or {} job_id = cb.get("job_id", "") url = cb.get("webhook_url", "") + # The backend sends the status it actually terminalized the row to, so a + # finalize announces `completed` or `failed` from the row itself rather + # than this worker guessing. `default_status` covers an enqueue from an + # older producer that did not send one. + status = cb.get("status") or default_status if not url: return None try: - delivered = _send_webhook(url, job_id, "cancelled") + delivered = _send_webhook(url, job_id, status) except Exception: - logger.exception("agent_kv_cancelled webhook failed: job_id=%s", job_id) + logger.exception( + "agent_kv terminal webhook failed: job_id=%s status=%s", job_id, status + ) raise if not delivered: # `send_webhook` returns False for THREE different things: a non-2xx @@ -279,20 +266,85 @@ def agent_kv_cancelled( # message is acknowledged, because nothing about repeating this # will change the outcome. logger.error( - "agent_kv_cancelled: webhook_url for job %s is permanently " - "undeliverable (%s); not retrying", + "agent-kv: webhook_url for job %s is permanently undeliverable " + "(%s); not retrying", job_id, reason, ) - return {"job_id": job_id, "status": "cancelled", "webhook_skipped": reason} + return {"job_id": job_id, "status": status, "webhook_skipped": reason} logger.error( - "agent_kv_cancelled: webhook not delivered for job %s (retry %s of %s)", + "agent-kv: %s webhook not delivered for job %s (retry %s of %s)", + status, job_id, - self.request.retries, - self.max_retries, + task.request.retries, + task.max_retries, ) - raise RuntimeError(f"cancellation webhook not delivered for job {job_id}") - return {"job_id": job_id, "status": "cancelled"} + raise RuntimeError(f"{status} webhook not delivered for job {job_id}") + return {"job_id": job_id, "status": status} + + +@worker_task( + bind=True, + name="agent_kv_cancelled", + autoretry_for=(Exception,), + max_retries=3, + retry_backoff=True, + retry_backoff_max=300, + retry_jitter=True, +) +def agent_kv_cancelled( + self, + callback_kwargs: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """Fire the terminal webhook for a job cancelled by the API. + + Cancellation does not go through finalize: ``JobCancelView`` (and DELETE + on a non-terminal job) terminalizes the row directly, releases the slot + and returns. So nothing in the finalize path ever fired a webhook for it, + and a late executor callback cannot either -- it loses the terminal guard + and ``_maybe_webhook`` correctly declines a non-fresh finalize. The caller + who supplied ``webhook_url`` was simply never told, despite docs §8 + promising delivery on terminal states. + + Enqueued by the backend ONLY when its guarded cancel actually won, which + is what keeps this from double-notifying: if the cancel lost to a + finalize, the backend does not enqueue and ``agent_kv_finalized`` is + queued instead. Exactly one of the two paths owns the notification for any + given job. + """ + return _deliver_terminal_webhook(self, callback_kwargs, "cancelled") + + +@worker_task( + bind=True, + name="agent_kv_finalized", + autoretry_for=(Exception,), + max_retries=3, + retry_backoff=True, + retry_backoff_max=300, + retry_jitter=True, +) +def agent_kv_finalized( + self, + callback_kwargs: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """Fire the terminal webhook for a job that finalize just terminalized. + + The counterpart to ``agent_kv_cancelled``, and enqueued the same way: by + the backend, from the request whose ``mark_terminal`` actually won. The + two are mutually exclusive, so a job gets exactly one terminal + notification. + + This used to be sent inline by ``_maybe_webhook`` right after the finalize + call returned. That lost the notification whenever the finalize RESPONSE + was lost: the worker retried, the replay found an already-terminal row and + reported ``finalized: False``, and the worker declined to send -- correctly, + because out here a non-fresh finalize is indistinguishable from the + ordinary duplicate-callback case where the webhook already went out. + Queuing it from the backend instead ties the notification to the guard + rather than to the survival of an HTTP response. + """ + return _deliver_terminal_webhook(self, callback_kwargs, "completed") def _send_webhook( @@ -324,50 +376,30 @@ def _send_webhook( def _maybe_webhook(finalize_response: dict[str, Any], job_id: str) -> None: - """Fire the completion webhook, but only for a fresh finalize. - - ``finalized`` is ``False`` for a duplicate/late finalize call (the job - was already terminal) as well as for an unknown job -- either way the - webhook already fired (or never should), so firing again here would - double-notify the caller. - - A job cancelled through the API is the "already terminal" case, and its - notification is owned by ``agent_kv_cancelled``, enqueued by the backend at - the moment its guarded cancel won. - - **One non-fresh finalize does still notify**, and it is the exception that - the ``result_attached`` flag exists for: the job was cancelled after its - extraction had already finished, so the backend kept the paid-for result on - the cancelled row instead of discarding it. The cancellation webhook went - out the moment that cancel won, and nothing follows it -- so a - webhook-driven caller, who has no reason to poll a job they were told was - cancelled, would never learn the answer is there to read. See - ``AgentKVJob.attach_result_to_cancelled``. + """Send the ONE follow-up the backend cannot queue for itself. + + The terminal notification is no longer sent here. The backend queues it + (`agent_kv_finalized`) from the request whose `mark_terminal` won, exactly + as it has always done for cancellation (`agent_kv_cancelled`). Sending it + here meant it rode on the finalize RESPONSE surviving: if that response was + lost, this worker retried, the replay reported `finalized: False`, and the + send was skipped -- correctly, because from out here a non-fresh finalize + is indistinguishable from the ordinary duplicate-callback case where the + webhook already went out. The caller was never told and nothing recorded + the miss. + + What remains is the one case the backend genuinely cannot queue, because + it is not a terminal transition at all: the job was CANCELLED after its + extraction had already finished, so the backend kept the paid-for result + on the cancelled row. The cancellation webhook went out the moment that + cancel won and nothing follows it -- so a webhook-driven caller, who has + no reason to poll a job they were told was cancelled, would never learn + the answer is there to read. `result_attached` is set only on that path; + see `AgentKVJob.attach_result_to_cancelled`. """ url = finalize_response.get("webhook_url") or "" - if not finalize_response.get("finalized"): - if url and finalize_response.get("result_attached"): - _notify_result_available(url, job_id) - return - if not url: - return - status = finalize_response.get("status", "") - if not _send_webhook(url, job_id, status): - # `send_webhook` returns False for a non-2xx or a connection failure. - # Discarding that meant a terminal notification could fail to land with - # no retry, no record on the job, and -- for a non-2xx -- not even a log - # line. Logged at ERROR here rather than raised: unlike the cancellation - # task, this runs AFTER finalize has already terminalized the job, so - # raising would re-run a finalize that is no longer idempotent-free. - # - # Durable delivery (attempt counts, a `webhook_delivered_at` the status - # document can expose) is the open design question in 2.10. - logger.error( - "agent-kv webhook not delivered for job %s (status=%s); the caller " - "was not notified and nothing will retry", - job_id, - status, - ) + if url and finalize_response.get("result_attached"): + _notify_result_available(url, job_id) def _notify_result_available(url: str, job_id: str) -> None: diff --git a/workers/ide_callback/tests/test_agent_kv_callbacks.py b/workers/ide_callback/tests/test_agent_kv_callbacks.py index 64de0e83a2..327ffbd479 100644 --- a/workers/ide_callback/tests/test_agent_kv_callbacks.py +++ b/workers/ide_callback/tests/test_agent_kv_callbacks.py @@ -437,9 +437,19 @@ def _call_complete(self, callback_kwargs, api): return agent_kv_complete(result_dict, callback_kwargs) @patch(_PATCH_SEND_WEBHOOK) - def test_webhook_fires_when_finalized_true_and_url_present( + def test_a_fresh_finalize_sends_nothing_from_here( self, mock_send_webhook, cb_kwargs ): + """The terminal webhook is the BACKEND's to queue, not this worker's. + + It used to be sent right here, which tied the notification to the + finalize RESPONSE surviving: lose that response and the retry saw an + already-terminal row, reported `finalized: False`, and this declined + to send -- correctly, since a non-fresh finalize is indistinguishable + from the ordinary duplicate whose webhook already went out. The caller + was never told. `FinalizeView` now enqueues `agent_kv_finalized` on + the request that won the guard instead. + """ api = MagicMock() api.agent_kv_finalize.return_value = { "finalized": True, @@ -449,11 +459,7 @@ def test_webhook_fires_when_finalized_true_and_url_present( self._call_complete(cb_kwargs, api) - mock_send_webhook.assert_called_once_with( - "https://example.com/hook", - {"job_id": "job-1", "status": "completed"}, - allow_insecure=False, - ) + mock_send_webhook.assert_not_called() @patch(_PATCH_SEND_WEBHOOK) def test_webhook_not_fired_on_duplicate_finalize_false( @@ -488,7 +494,13 @@ def test_webhook_not_fired_when_url_empty(self, mock_send_webhook, cb_kwargs): mock_send_webhook.assert_not_called() @patch(_PATCH_SEND_WEBHOOK) - def test_webhook_fires_from_error_link_too(self, mock_send_webhook, cb_kwargs): + def test_the_error_link_sends_nothing_from_here_either( + self, mock_send_webhook, cb_kwargs + ): + """Same ownership change as the success path: a FAILED job's terminal + webhook is queued by the backend, which knows it terminalized the row + to `failed`, rather than sent from here on the finalize response. + """ from ide_callback.agent_kv_tasks import agent_kv_error api = MagicMock() @@ -504,59 +516,72 @@ def test_webhook_fires_from_error_link_too(self, mock_send_webhook, cb_kwargs): ): agent_kv_error("failed-task-5", cb_kwargs) - mock_send_webhook.assert_called_once_with( - "https://example.com/hook", - {"job_id": "job-1", "status": "failed"}, - allow_insecure=False, - ) + mock_send_webhook.assert_not_called() class TestWebhookDeliveryResult: """A webhook that does not land must not pass silently. `send_webhook` returns False for a non-2xx response or a connection - failure, and both call sites used to discard it. The cancellation task now - raises (so its retry budget applies); the finalize path logs, because it - runs after the job is already terminalized and re-running finalize is not - free. Reported as 2.10 in the branch review and by Greptile on #2317. + failure, and both call sites used to discard it. BOTH terminal + notifications are now queued tasks, so both raise and both get a retry + budget -- the finalize one used to only be able to log, because it ran + inline after the job was already terminalized and re-running finalize is + not free. Moving it onto the queue is what gave it somewhere to fail to. + Reported as 2.10 in the branch review and by Greptile on #2317. """ @patch(_PATCH_SEND_WEBHOOK, return_value=False) - def test_a_failed_finalize_webhook_is_logged(self, mock_send, caplog): - import logging - - from ide_callback import agent_kv_tasks as akt - - with caplog.at_level(logging.ERROR, logger="ide_callback.agent_kv_tasks"): - akt._maybe_webhook( - { - "finalized": True, - "webhook_url": "https://hook.example/x", - "status": "completed", - }, - "job-1", - ) + def test_an_undelivered_finalize_webhook_retries(self, mock_send): + """The finalize notification is now a queued task, so an undelivered + one RAISES and the retry budget applies -- it no longer just logs. + + That was the whole point of moving it off the inline path: logging was + the best it could do there, because it ran after the job was already + terminal and re-running finalize is not free. A task has somewhere to + fail to. + """ + from ide_callback.agent_kv_tasks import agent_kv_finalized - assert "not delivered" in caplog.text - assert "job-1" in caplog.text + with pytest.raises(RuntimeError, match="webhook not delivered for job job-1"): + agent_kv_finalized.apply( + kwargs={ + "callback_kwargs": { + "job_id": "job-1", + "webhook_url": "https://hook.example/x", + "status": "completed", + } + } + ).get() @patch(_PATCH_SEND_WEBHOOK, return_value=True) - def test_a_delivered_finalize_webhook_is_quiet(self, mock_send, caplog): - import logging - - from ide_callback import agent_kv_tasks as akt + def test_a_delivered_finalize_webhook_announces_the_backend_s_status( + self, mock_send + ): + """The status comes from the backend, which knows what it terminalized + the row to -- this worker does not guess. `failed` proves it is read + from the payload rather than defaulted to `completed`. + """ + from ide_callback.agent_kv_tasks import agent_kv_finalized - with caplog.at_level(logging.ERROR, logger="ide_callback.agent_kv_tasks"): - akt._maybe_webhook( - { - "finalized": True, + out = agent_kv_finalized.apply( + kwargs={ + "callback_kwargs": { + "job_id": "job-1", "webhook_url": "https://hook.example/x", - "status": "completed", - }, - "job-1", - ) + "status": "failed", + } + } + ).get() - assert caplog.records == [] + # The frozen `{job_id, status}` shape (spec 6.7/7.1) -- the terminal + # notification carries no `result_available`. + mock_send.assert_called_once_with( + "https://hook.example/x", + {"job_id": "job-1", "status": "failed"}, + allow_insecure=False, + ) + assert out == {"job_id": "job-1", "status": "failed"} @patch(_PATCH_SEND_WEBHOOK, return_value=False) def test_a_failed_cancellation_webhook_raises_so_it_retries(self, mock_send): @@ -676,24 +701,32 @@ def test_an_undelivered_follow_up_is_logged_not_raised(self, mock_send, caplog): assert "job-1" in caplog.text @patch(_PATCH_SEND_WEBHOOK, return_value=True) - def test_the_fixed_payload_is_unchanged_for_every_other_notification(self, mock_send): - """`result_available` is additive and appears on exactly one - notification. Every other POST keeps the frozen `{job_id, status}` + def test_result_available_is_the_only_notification_carrying_it(self, mock_send): + """`result_available` is additive and appears on exactly ONE + notification; every other POST keeps the frozen `{job_id, status}` shape (spec section 6.7/7.1). + + The fresh-finalize half of this assertion moved to + `TestWebhookDeliveryResult`, where the terminal notification now + lives. Keeping it here would have asserted nothing: this path no + longer sends on a fresh finalize, so a `mock_send.assert_not_called()` + would pass whether or not the payload were right. """ from ide_callback import agent_kv_tasks as akt akt._maybe_webhook( { - "finalized": True, + "finalized": False, + "result_attached": True, "webhook_url": "https://hook.example/x", - "status": "completed", + "status": "cancelled", }, "job-1", ) - mock_send.assert_called_once_with( - "https://hook.example/x", - {"job_id": "job-1", "status": "completed"}, - allow_insecure=False, - ) + payload = mock_send.call_args[0][1] + assert payload == { + "job_id": "job-1", + "status": "cancelled", + "result_available": True, + } diff --git a/workers/shared/infrastructure/config/registry.py b/workers/shared/infrastructure/config/registry.py index 56fc84ff80..9e5f0cfd94 100644 --- a/workers/shared/infrastructure/config/registry.py +++ b/workers/shared/infrastructure/config/registry.py @@ -161,10 +161,17 @@ class WorkerRegistry: TaskRoute("ide_callback.tasks.*", QueueName.IDE_CALLBACK), TaskRoute("agent_kv_complete", QueueName.AGENT_KV_CALLBACK), TaskRoute("agent_kv_error", QueueName.AGENT_KV_CALLBACK), - # Cancellation does not go through finalize, so its terminal - # webhook has no callback to ride on; the backend enqueues this - # directly when its guarded cancel wins. + # Both terminal webhooks are enqueued by the BACKEND, from + # whichever request won the terminal guard, and are mutually + # exclusive: a cancel that won means no finalize did. + # + # Cancellation does not go through finalize at all, so its + # notification has no callback to ride on. Finalize's used to + # ride on one and was lost whenever the finalize response was + # lost -- the retry saw an already-terminal row, reported + # `finalized: False`, and the worker declined to send. TaskRoute("agent_kv_cancelled", QueueName.AGENT_KV_CALLBACK), + TaskRoute("agent_kv_finalized", QueueName.AGENT_KV_CALLBACK), ], ), } From 292a0196ffd4c0efb1d6ba1894f9e38cb81037da Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Wed, 14 Oct 2026 15:23:57 +0530 Subject: [PATCH 51/54] UN-4232 [FIX] Let finalize state who owns the webhook, instead of the worker assuming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile on the previous commit: a NEW callback worker talking to an OLD backend drops the terminal webhook entirely. The worker has stopped sending inline, the backend does not yet enqueue, and the callback is acknowledged with nothing delivered. My argument that this cannot happen -- the feature ships as one unit, because `backend/agent_kv/` does not exist on main -- holds for a release rollout but not for a dev namespace, where mixed builds of this branch do run against each other. The three combinations: new worker + new backend -> backend queues, worker quiet -> 1 new worker + OLD backend -> nobody sends -> 0 LOST old worker + new backend -> backend queues, old worker sends -> 2 dup So the finalize response now carries `webhook_enqueued`, and the worker's inline send is kept as a fallback gated on it. Absent or false means delivery is still the worker's -- which is exactly how an older backend's response reads, so no version check is involved. That turns the middle row into one notification. The last row stays a duplicate and cannot be fixed from the new code; a duplicate is the benign direction, and receivers of an at-least-once notification have to tolerate it anyway. Writing the test for this found a flaw in my own first cut of the flag. I had set it to `bool(job.webhook_url)` -- whether the job HAS a webhook, not whether one was queued. The enqueue is best-effort and swallows its exception, so a queue that is down would have reported True and told the worker to stand down with nothing queued: the same silent loss, reached a different way. `_enqueue_webhook` now returns what it actually did and the flag is that value, so a failed enqueue falls back to the inline path. Both semantics are pinned by their own tests rather than left to the three exact-dict contract assertions, which assert False only incidentally (there is no queue in a unit test). The fallback gate is mutation-checked: force it closed and the two rollout tests fail. The fallback is transitional and marked as such in both files and in docs §8 -- it carries the original replay defect by construction, because it is the old code path. A notification that can be lost on a replay still beats one that is never sent. 344 backend and 84 worker tests pass. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/dispatch.py | 24 ++++-- backend/agent_kv/internal_views.py | 23 +++++- backend/agent_kv/tests/test_internal_views.py | 53 +++++++++++++ docs/agent-kv-api.md | 6 ++ workers/ide_callback/agent_kv_tasks.py | 78 ++++++++++++++----- .../tests/test_agent_kv_callbacks.py | 48 ++++++++++++ 6 files changed, 204 insertions(+), 28 deletions(-) diff --git a/backend/agent_kv/dispatch.py b/backend/agent_kv/dispatch.py index 3e1e9cbd6b..55cb5620e2 100644 --- a/backend/agent_kv/dispatch.py +++ b/backend/agent_kv/dispatch.py @@ -20,7 +20,7 @@ class DispatchError(Exception): """Enqueue failed; the caller terminalizes the job (spec §5.3).""" -def _enqueue_webhook(task_name: str, job, status: str) -> None: +def _enqueue_webhook(task_name: str, job, status: str) -> bool: """Queue a terminal-webhook task for ``job``, best-effort. Shared by the cancel and finalize paths so the two cannot drift: both @@ -32,9 +32,16 @@ def _enqueue_webhook(task_name: str, job, status: str) -> None: already terminal and the caller already has their response, so failing that request because a notification could not be QUEUED would be the wrong trade. Logs and returns. + + Returns whether a task was actually queued, which is NOT the same as + "this job has a webhook". A queue that is down returns False, and + finalize reports that to the callback worker as `webhook_enqueued: + false` so the worker falls back to sending inline. Reporting True on a + swallowed failure would mean neither side sends -- the exact loss this + whole change exists to remove. """ if not job.webhook_url: - return + return False try: from pg_queue.producer import enqueue_task @@ -58,9 +65,11 @@ def _enqueue_webhook(task_name: str, job, status: str) -> None: job.id, status, ) + return False + return True -def dispatch_terminal_webhook(job, status: str) -> None: +def dispatch_terminal_webhook(job, status: str) -> bool: """Queue the terminal webhook for a job finalize just terminalized. Call ONLY when ``mark_terminal`` actually won, which is what makes this @@ -84,11 +93,14 @@ def dispatch_terminal_webhook(job, status: str) -> None: trip. Closing that too would mean enqueuing inside ``mark_terminal``'s transaction; it is not done here because the enqueue is deliberately best-effort and must not be able to roll back a terminal state. + + Returns whether the task was queued, which finalize passes to the + callback worker so it knows whether delivery is still its own. """ - _enqueue_webhook("agent_kv_finalized", job, status) + return _enqueue_webhook("agent_kv_finalized", job, status) -def dispatch_cancelled_webhook(job) -> None: +def dispatch_cancelled_webhook(job) -> bool: """Queue the terminal webhook for a job the API just cancelled. Cancellation never reaches finalize -- ``JobCancelView`` and DELETE @@ -115,7 +127,7 @@ def dispatch_cancelled_webhook(job) -> None: QUEUED would be the wrong trade, so this logs and returns rather than raising. Delivery itself is the worker's problem. """ - _enqueue_webhook("agent_kv_cancelled", job, "cancelled") + return _enqueue_webhook("agent_kv_cancelled", job, "cancelled") def _dispatcher(): diff --git a/backend/agent_kv/internal_views.py b/backend/agent_kv/internal_views.py index b6b56ce3f1..6de648dc23 100644 --- a/backend/agent_kv/internal_views.py +++ b/backend/agent_kv/internal_views.py @@ -415,6 +415,7 @@ def post(self, request, *args, job_id=None, **kwargs): job = AgentKVJob.objects.filter(id=job_id, organization_id=org_id).first() finalized = False result_attached = False + webhook_enqueued = False try: if job is not None and job.status not in AgentKVJob.TERMINAL: handler = self._complete if success else self._fail @@ -430,7 +431,12 @@ def post(self, request, *args, job_id=None, **kwargs): # whose webhook already went out. Mirrors what the cancel # path has always done, so exactly one of the two owns the # notification for any job. - dispatch_terminal_webhook(job, job.status.lower()) + # The flag is what the enqueue ACTUALLY did, not merely + # whether the job has a webhook: a queue that is down + # returns False, and the worker then falls back to + # sending inline. Reporting True on a swallowed enqueue + # failure would leave neither side sending. + webhook_enqueued = dispatch_terminal_webhook(job, job.status.lower()) self._drop_staged_input(job, job_id, org_id) elif job is not None and success and job.status == JobStatus.CANCELLED: # The job was already CANCELLED when this request read it, and @@ -469,6 +475,21 @@ def post(self, request, *args, job_id=None, **kwargs): # then, so the callback worker sends a follow-up (see # `_maybe_webhook`). Additive: every other path reports False. "result_attached": result_attached, + # Says "I have queued the terminal webhook; do not send one". + # + # Exists so the callback worker does not have to ASSUME which + # side owns delivery. A worker new enough to have stopped + # sending inline, talking to a backend old enough not to + # enqueue, would otherwise drop the notification entirely: + # neither side sends and the callback is acknowledged. Absent + # or False means the worker still owns it, which is exactly + # how an older backend's response reads. + # + # Transitional. Once no backend predating this field can be + # running -- it ships with the feature, so that is the first + # release carrying agent_kv at all -- this and the worker's + # fallback can both go. + "webhook_enqueued": webhook_enqueued, } ) diff --git a/backend/agent_kv/tests/test_internal_views.py b/backend/agent_kv/tests/test_internal_views.py index 151eba292d..075f850367 100644 --- a/backend/agent_kv/tests/test_internal_views.py +++ b/backend/agent_kv/tests/test_internal_views.py @@ -370,6 +370,7 @@ def test_finalize_success_writes_result_then_marks_terminal( # kept on a row a cancel had already terminalized. See # `test_finalize_guard_lost_to_a_cancel_keeps_the_paid_for_result`. "result_attached": False, + "webhook_enqueued": False, } assert [c[0] for c in manager.mock_calls] == ["write_result", "mark_terminal"] m_write.assert_called_once_with("org1", str(job_id), {"foo": "bar"}) @@ -735,6 +736,7 @@ def test_finalize_duplicate_does_not_rewrite_result( # kept on a row a cancel had already terminalized. See # `test_finalize_guard_lost_to_a_cancel_keeps_the_paid_for_result`. "result_attached": False, + "webhook_enqueued": False, } assert not m_write.called assert not m_mark_terminal.called @@ -796,6 +798,7 @@ def test_finalize_failure_records_error(m_jobs, m_write, m_mark_terminal, m_rele # kept on a row a cancel had already terminalized. See # `test_finalize_guard_lost_to_a_cancel_keeps_the_paid_for_result`. "result_attached": False, + "webhook_enqueued": False, } assert not m_write.called m_mark_terminal.assert_called_once_with( @@ -1151,3 +1154,53 @@ def test_the_notification_does_not_depend_on_the_response_being_delivered( assert queued_before_response == [True] assert resp.status_code == 200 + + +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "dispatch_terminal_webhook", return_value=True) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(iv, "write_result", return_value="ref") +@mock.patch.object(AgentKVJob, "objects") +def test_a_queued_webhook_tells_the_worker_not_to_send_one( + m_jobs, m_write, m_mark, m_dispatch, m_release +): + """`webhook_enqueued: true` is how the worker learns delivery is not its + job. Without it a worker that has stopped sending inline, talking to a + backend that does not enqueue, drops the notification entirely.""" + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {}}), + job_id=uuid.uuid4(), + ) + + assert resp.data["webhook_enqueued"] is True + + +@mock.patch.object(iv.AgentKVConcurrencyLimiter, "release") +@mock.patch.object(iv, "dispatch_terminal_webhook", return_value=False) +@mock.patch.object(AgentKVJob, "mark_terminal", return_value=True) +@mock.patch.object(iv, "write_result", return_value="ref") +@mock.patch.object(AgentKVJob, "objects") +def test_an_enqueue_that_failed_hands_delivery_back_to_the_worker( + m_jobs, m_write, m_mark, m_dispatch, m_release +): + """The flag reports what the enqueue DID, not whether the job has a + webhook. + + The enqueue is best-effort and swallows its exception, so a queue that is + down returns False. Reporting True there would tell the worker to stand + down when nothing had been queued -- neither side sends, which is the + exact loss this change exists to remove. Fails back to the inline path + instead. + """ + job = AgentKVJob(status=JobStatus.RUNNING, webhook_url="https://example.com/hook") + m_jobs.filter.return_value.first.return_value = job + + resp = iv.FinalizeView.as_view()( + _post("/x", {"org_id": "org1", "success": True, "result": {}}), + job_id=uuid.uuid4(), + ) + + assert resp.data["webhook_enqueued"] is False diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 81df46d496..52ce862440 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -873,6 +873,12 @@ the permanently-undeliverable case belong to the `ide_callback` worker. > indistinguishable from the ordinary duplicate callback whose webhook already went > out. The effect was that the caller was never notified and nothing recorded the > miss. +> +> The finalize response carries `webhook_enqueued` so the worker never has to +> *assume* which side owns delivery: absent or `false` — which is exactly how a +> backend predating this field answers, and also how a backend whose enqueue +> failed answers — means the worker still sends inline. Transitional, and removable +> once no such backend can be running. **There is exactly one case of a second POST**, and it is additive: diff --git a/workers/ide_callback/agent_kv_tasks.py b/workers/ide_callback/agent_kv_tasks.py index 5846754d43..4f534cf3fb 100644 --- a/workers/ide_callback/agent_kv_tasks.py +++ b/workers/ide_callback/agent_kv_tasks.py @@ -376,29 +376,65 @@ def _send_webhook( def _maybe_webhook(finalize_response: dict[str, Any], job_id: str) -> None: - """Send the ONE follow-up the backend cannot queue for itself. - - The terminal notification is no longer sent here. The backend queues it - (`agent_kv_finalized`) from the request whose `mark_terminal` won, exactly - as it has always done for cancellation (`agent_kv_cancelled`). Sending it - here meant it rode on the finalize RESPONSE surviving: if that response was - lost, this worker retried, the replay reported `finalized: False`, and the - send was skipped -- correctly, because from out here a non-fresh finalize - is indistinguishable from the ordinary duplicate-callback case where the - webhook already went out. The caller was never told and nothing recorded - the miss. - - What remains is the one case the backend genuinely cannot queue, because - it is not a terminal transition at all: the job was CANCELLED after its - extraction had already finished, so the backend kept the paid-for result - on the cancelled row. The cancellation webhook went out the moment that - cancel won and nothing follows it -- so a webhook-driven caller, who has - no reason to poll a job they were told was cancelled, would never learn - the answer is there to read. `result_attached` is set only on that path; - see `AgentKVJob.attach_result_to_cancelled`. + """Send whichever notifications the BACKEND did not take ownership of. + + Normally that is just one: the `result_available` follow-up. The terminal + notification is queued by the backend (`agent_kv_finalized`) from the + request whose `mark_terminal` won, exactly as it has always been for + cancellation (`agent_kv_cancelled`). + + It used to be sent from here, which tied it to the finalize RESPONSE + surviving: lose that response and this worker retried, the replay reported + `finalized: False`, and the send was skipped -- correctly, because from out + here a non-fresh finalize is indistinguishable from the ordinary duplicate + whose webhook already went out. The caller was never told and nothing + recorded the miss. + + **The inline send is kept as a fallback, gated on the backend saying it + did NOT queue one.** Which side owns delivery is now stated in the + response rather than assumed: a worker that stopped sending inline, + talking to a backend that does not yet enqueue, would drop the + notification entirely -- neither side sends, and the callback is + acknowledged. `webhook_enqueued` absent or False means delivery is still + this worker's, which is exactly how an older backend's response reads, so + no version check is needed. + + Transitional, and worth deleting: once no backend predating + `webhook_enqueued` can be running, this branch and the field go together. + The fallback carries the original defect by construction -- it is the old + code path -- but a notification that can be lost on a replay beats one + that is never sent at all. """ url = finalize_response.get("webhook_url") or "" - if url and finalize_response.get("result_attached"): + if not url: + return + + if finalize_response.get("finalized"): + if finalize_response.get("webhook_enqueued"): + return + logger.warning( + "agent-kv: finalize for job %s did not queue a terminal webhook " + "(backend predates `webhook_enqueued`); sending it inline. This " + "delivery is lost if the finalize response does not survive", + job_id, + ) + status = finalize_response.get("status", "") + if not _send_webhook(url, job_id, status): + logger.error( + "agent-kv webhook not delivered for job %s (status=%s); the " + "caller was not notified and nothing will retry", + job_id, + status, + ) + return + + # Not a terminal transition, so there is no guard for the backend to hang + # it on: the job was CANCELLED after its extraction had already finished, + # so the backend kept the paid-for result on the cancelled row. The + # cancellation webhook went out when that cancel won and nothing follows + # it -- a webhook-driven caller, with no reason to poll a job they were + # told was cancelled, would never learn the answer is there to read. + if finalize_response.get("result_attached"): _notify_result_available(url, job_id) diff --git a/workers/ide_callback/tests/test_agent_kv_callbacks.py b/workers/ide_callback/tests/test_agent_kv_callbacks.py index 327ffbd479..513b004099 100644 --- a/workers/ide_callback/tests/test_agent_kv_callbacks.py +++ b/workers/ide_callback/tests/test_agent_kv_callbacks.py @@ -455,12 +455,59 @@ def test_a_fresh_finalize_sends_nothing_from_here( "finalized": True, "webhook_url": "https://example.com/hook", "status": "completed", + "webhook_enqueued": True, } self._call_complete(cb_kwargs, api) mock_send_webhook.assert_not_called() + @patch(_PATCH_SEND_WEBHOOK) + def test_a_backend_that_did_not_queue_one_still_gets_an_inline_send( + self, mock_send_webhook, cb_kwargs + ): + """The rollout case: this worker has stopped sending inline, but the + backend answering it is old enough not to enqueue. + + Without the gate BOTH sides stay silent and the notification is lost + outright -- the job finishes, the callback is acknowledged, nothing is + delivered. `webhook_enqueued` absent is exactly how an older backend's + response reads, so no version check is involved. + """ + api = MagicMock() + api.agent_kv_finalize.return_value = { + "finalized": True, + "webhook_url": "https://example.com/hook", + "status": "completed", + # note: no `webhook_enqueued` + } + + self._call_complete(cb_kwargs, api) + + mock_send_webhook.assert_called_once_with( + "https://example.com/hook", + {"job_id": "job-1", "status": "completed"}, + allow_insecure=False, + ) + + @patch(_PATCH_SEND_WEBHOOK) + def test_an_explicit_false_is_treated_the_same_as_absent( + self, mock_send_webhook, cb_kwargs + ): + """A job with no `webhook_url` reports False, but so would a backend + that tried to queue one and could not. Both mean "still yours".""" + api = MagicMock() + api.agent_kv_finalize.return_value = { + "finalized": True, + "webhook_url": "https://example.com/hook", + "status": "completed", + "webhook_enqueued": False, + } + + self._call_complete(cb_kwargs, api) + + assert mock_send_webhook.call_count == 1 + @patch(_PATCH_SEND_WEBHOOK) def test_webhook_not_fired_on_duplicate_finalize_false( self, mock_send_webhook, cb_kwargs @@ -508,6 +555,7 @@ def test_the_error_link_sends_nothing_from_here_either( "finalized": True, "webhook_url": "https://example.com/hook", "status": "failed", + "webhook_enqueued": True, } with ( From 8095a8c1d240ae642f649cd55aca097a488702fb Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Thu, 15 Oct 2026 10:34:01 +0530 Subject: [PATCH 52/54] UN-4232 [FIX] The sweep owes a terminal webhook too; exempt submit from ATOMIC_REQUESTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Greptile findings on the main-merge, both valid. **1. A swept job notified nobody.** `run_sweep` is the THIRD route to a terminal state, after finalize and cancel, and docs §8 promises a POST on every one. Phases 1 and 2 mark a job FAILED -- dispatch lost, or executor dead -- release its slot and send nothing. No later callback can cover for it: one arriving afterwards finds an already-terminal row, is told `finalized: False`, and correctly declines to send, because from the worker a non-fresh finalize is indistinguishable from the ordinary duplicate. So a caller whose job timed out was told it was accepted and then never heard again. This predates today's change rather than being caused by it -- the sweep never sent a webhook -- but the change makes it a two-line fix and raises the bar: the backend now owns terminal notifications, and the sweep was the only terminalizing path left not doing it. Both phases now queue `dispatch_terminal_webhook(job, "failed")`, inside the `if won` that already gates the slot release, so the guard decides who notifies, exactly as on the other two paths. Phase 3 deliberately still sends nothing, and that asymmetry is now written down: it terminalizes nothing, it only frees a slot on a row that is already CANCELLED and whose notification went out when the cancel won. **2. Sync-wait would always time out under ATOMIC_REQUESTS.** A sync-wait submit polls for the job to terminalize while still inside the request. With `DJANGO_ATOMIC_REQUESTS=True` neither the job row nor the queue insert is committed during that poll, so no worker can see the work, the job cannot start, and every positive `timeout` burns its full duration and returns 202 -- silently; the caller just never gets the inline result. The flag defaults to False and ships False, so nothing hits this today. The same precondition is already documented for the RPC path in `pg_queue/executor_rpc.py` ("dispatch must NOT be called inside an open transaction"), so this is a known platform-wide hazard rather than an agent-kv one -- but the view costs one wrapper to make correct either way, and it needs nothing from an implicit transaction: the job insert is one statement and every terminal write is its own guarded UPDATE. Applied in the URLconf, which is where Django reads the marker from; applying it to the class would silently not take. Tests: four for the sweep (both phases notify; phase 3 does not; a job whose guard was LOST notifies nobody and releases nothing). Mock-backed, not `TestCase`, so they assert the calls `run_sweep` makes and run without a database. Mutation-checked -- drop the two enqueues and both positive tests fail. The two negatives pass either way by design: they guard against the notification widening, not against its absence. Two more for the exemption, asserted through the resolved URLconf rather than the imported class, since the class-level assertion would pass while the routed view stayed atomic; also mutation-checked. 350 backend and 84 worker tests pass. Co-Authored-By: Claude Opus 5 --- backend/agent_kv/execution_urls.py | 25 +++++- backend/agent_kv/maintenance.py | 22 +++++ backend/agent_kv/tests/test_submit_view.py | 46 +++++++++++ backend/agent_kv/tests/test_sweeps.py | 96 ++++++++++++++++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) diff --git a/backend/agent_kv/execution_urls.py b/backend/agent_kv/execution_urls.py index eff4807722..87a80da82c 100644 --- a/backend/agent_kv/execution_urls.py +++ b/backend/agent_kv/execution_urls.py @@ -12,6 +12,7 @@ rewrites most; leaving them makes restoring this endpoint one line. """ +from django.db import transaction from django.urls import path from agent_kv.execution_views import ( @@ -22,7 +23,29 @@ ) urlpatterns = [ - path("", SubmitView.as_view(), name="agent_kv_submit"), + # `non_atomic_requests`, and only on submit. A sync-wait submit + # (`timeout` > 0) polls for the job to terminalize while still inside the + # request. Under `DJANGO_ATOMIC_REQUESTS=True` the whole view runs in one + # transaction, so neither the job row nor the queue insert is COMMITTED + # while that poll runs -- no worker can see work that is not yet visible, + # so the job cannot start, and every positive `timeout` burns its full + # duration and then returns 202. The caller is not told; they just never + # get the inline result the flag was supposed to give them. + # + # The flag defaults to False and ships False (`charts/unstract-platform/ + # values.yaml`), so nothing hits this today -- this keeps the view correct + # if an operator ever turns it on, rather than leaving a silent + # degradation behind a config switch. The same precondition is already + # documented for the RPC path in `pg_queue/executor_rpc.py` + # (`wait_for_result`): dispatch must not be called inside an open + # transaction. This view needs nothing from an implicit transaction -- + # the job insert is one statement and every terminal write is its own + # guarded UPDATE. + path( + "", + transaction.non_atomic_requests(SubmitView.as_view()), + name="agent_kv_submit", + ), path("", JobStatusView.as_view(), name="agent_kv_status"), path("/result", JobResultView.as_view(), name="agent_kv_result"), path("/cancel", JobCancelView.as_view(), name="agent_kv_cancel"), diff --git a/backend/agent_kv/maintenance.py b/backend/agent_kv/maintenance.py index c72108d964..0de8a3e88a 100644 --- a/backend/agent_kv/maintenance.py +++ b/backend/agent_kv/maintenance.py @@ -27,6 +27,7 @@ from django.db.models.functions import Coalesce from django.utils import timezone +from agent_kv.dispatch import dispatch_terminal_webhook from agent_kv.models import AgentKVJob, JobStatus from agent_kv.rate_limiter import SLOT_TTL_SECONDS, AgentKVConcurrencyLimiter from agent_kv.storage import delete_job_files @@ -141,6 +142,15 @@ def run_sweep() -> dict: # worth releasing here -- one a concurrent finalize/cancel # won instead already released its own slot on that path. AgentKVConcurrencyLimiter.release(str(org_id), str(job.id)) + # ...and only such a job owes a terminal notification. The sweep + # is a THIRD path to a terminal state, alongside finalize and + # cancel, and docs §8 promises a POST on every one of them. It + # used to send nothing: a job whose dispatch was lost reached + # FAILED in silence, and no later callback could cover for it -- + # it would find an already-terminal row, report `finalized: + # False`, and correctly decline to send. The caller simply never + # heard again about a job they were told was accepted. + dispatch_terminal_webhook(job, "failed") stuck_cutoff = now - timedelta(seconds=settings.AGENT_KV_STUCK_JOB_GRACE_SECONDS) # `dispatched_at__lt` OR `dispatched_at IS NULL`, not just the former. @@ -182,6 +192,10 @@ def run_sweep() -> dict: if won: timed_out += 1 AgentKVConcurrencyLimiter.release(str(org_id), str(job.id)) + # Same as phase 1: this call terminalized the job, so this call + # owes the notification. Best-effort, like every other enqueue + # site -- a queue failure must not abort the rest of the batch. + dispatch_terminal_webhook(job, "failed") # Phase 3: release slots still held by jobs that were CANCELLED after being # dispatched. @@ -266,6 +280,14 @@ def run_sweep() -> dict: released = 0 for job in abandoned_cancelled: + # No `dispatch_terminal_webhook` here, unlike phases 1 and 2, and that + # asymmetry is deliberate rather than an omission. This phase does not + # TERMINALIZE anything -- it only frees a slot on a row that is already + # CANCELLED, and whose notification `agent_kv_cancelled` sent the + # moment that cancel won. Sending another would be a duplicate telling + # the caller nothing new; the one follow-up a cancelled job can still + # owe is `result_available`, which the callback worker handles. + # # `release` is an idempotent zrem, so re-releasing a slot the callback # already freed costs nothing and is not worth a guard. AgentKVConcurrencyLimiter.release(str(job.organization_id), str(job.id)) diff --git a/backend/agent_kv/tests/test_submit_view.py b/backend/agent_kv/tests/test_submit_view.py index 37c1728edf..09c6c6aa76 100644 --- a/backend/agent_kv/tests/test_submit_view.py +++ b/backend/agent_kv/tests/test_submit_view.py @@ -1204,3 +1204,49 @@ def test_an_upload_over_the_memory_threshold_is_not_a_500(): # And the multi-valued field must still be multi-valued -- a plain # `dict(request.data)` would keep only the last `tags` entry. assert data.getlist("tags") == ["a", "b"] + + +# --------------------------------------------------------------------------- +# Sync-wait must not run inside the request transaction +# --------------------------------------------------------------------------- + + +def test_submit_is_exempt_from_atomic_requests(): + """A sync-wait submit polls for the job to terminalize while still inside + the request. Under `DJANGO_ATOMIC_REQUESTS=True` the job row and the queue + insert would stay uncommitted for that whole poll, so no worker could see + the work, the job could never start, and every positive `timeout` would + burn its full duration and return 202 -- silently, with the caller simply + never getting the inline result. + + Django reads this marker off the view callable that `as_view()` produced, + so it has to be applied in the URLconf; asserting on the imported class + would pass while the routed view stayed atomic. Reported by Greptile on + #2317. + """ + from django.urls import get_resolver, reverse + + match = get_resolver().resolve(reverse("agent_kv_submit")) + + assert getattr(match.func, "_non_atomic_requests", set()), ( + "the routed submit view is not exempt from ATOMIC_REQUESTS; a " + "sync-wait submit would always time out if the flag were enabled" + ) + + +def test_the_other_agent_kv_routes_are_not_exempted(): + """Scoped to submit. Status/result/cancel do no in-request waiting, so + they have no reason to opt out of a transaction the deployment may want. + """ + from django.urls import get_resolver, reverse + + resolver = get_resolver() + for name, kwargs in ( + ("agent_kv_status", {"job_id": "00000000-0000-0000-0000-000000000001"}), + ("agent_kv_result", {"job_id": "00000000-0000-0000-0000-000000000001"}), + ("agent_kv_cancel", {"job_id": "00000000-0000-0000-0000-000000000001"}), + ): + match = resolver.resolve(reverse(name, kwargs=kwargs)) + assert not getattr(match.func, "_non_atomic_requests", set()), ( + f"{name} is exempt from ATOMIC_REQUESTS but has no in-request wait" + ) diff --git a/backend/agent_kv/tests/test_sweeps.py b/backend/agent_kv/tests/test_sweeps.py index c4aded3a9d..5caca5b482 100644 --- a/backend/agent_kv/tests/test_sweeps.py +++ b/backend/agent_kv/tests/test_sweeps.py @@ -1127,3 +1127,99 @@ def test_the_grace_is_its_own_setting_and_shorter_than_the_slot_ttl(self): passes except the first. """ assert settings.AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS < SLOT_TTL_SECONDS + + +# --------------------------------------------------------------------------- +# The sweep owes a terminal notification too +# --------------------------------------------------------------------------- +# Mock-backed rather than `TestCase`, deliberately: these assert which calls +# `run_sweep` makes, not what the database ends up holding, and keeping them +# off the DB means they run anywhere. +# +# Reported by Greptile on #2317. The sweep is the THIRD route to a terminal +# state, after finalize and cancel, and docs §8 promises a POST on every one. +# It sent nothing, and no later callback could cover for it: a callback +# arriving after the sweep finds an already-terminal row, reports +# `finalized: False`, and correctly declines to send. A caller whose job timed +# out was simply never told. + + +def _qs(rows): + """A queryset stand-in whose `.order_by(...)[:n]` yields `rows`.""" + qs = mock.MagicMock() + qs.order_by.return_value.__getitem__.return_value = rows + return qs + + +def _job(**kw): + j = mock.MagicMock() + j.id = uuid.uuid4() + j.organization_id = uuid.uuid4() + j.webhook_url = kw.get("webhook_url", "https://hook.example/x") + return j + + +def _run_sweep_with(phase1, phase2, phase3): + """Drive `run_sweep` with a fixed candidate list per phase. + + `objects.filter` is called once per phase, except phase 2 which chains a + second `.filter(...)` onto the first. + """ + qs2_outer = mock.MagicMock() + qs2_outer.filter.return_value = _qs(phase2) + with ( + mock.patch.object(maintenance.AgentKVJob, "objects") as m_objects, + mock.patch.object( + maintenance.AgentKVJob, "mark_terminal", return_value=True + ) as m_mark, + mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release"), + mock.patch.object(maintenance, "dispatch_terminal_webhook") as m_hook, + ): + m_objects.filter.side_effect = [_qs(phase1), qs2_outer, _qs(phase3)] + maintenance.run_sweep() + return m_hook, m_mark + + +def test_a_never_dispatched_job_the_sweep_fails_gets_its_webhook(): + job = _job() + m_hook, _ = _run_sweep_with([job], [], []) + + m_hook.assert_called_once_with(job, "failed") + + +def test_a_stuck_job_the_sweep_times_out_gets_its_webhook(): + job = _job() + m_hook, _ = _run_sweep_with([], [job], []) + + m_hook.assert_called_once_with(job, "failed") + + +def test_an_abandoned_cancelled_slot_release_notifies_nobody(): + """Phase 3 frees a slot on a row that is ALREADY cancelled; its + notification went out when the cancel won. A second POST would repeat + what the caller was told and carry nothing new.""" + m_hook, _ = _run_sweep_with([], [], [_job()]) + + m_hook.assert_not_called() + + +def test_a_job_the_sweep_did_not_terminalize_gets_no_webhook(): + """The guard is what decides, not the SELECT. A row a concurrent + finalize or cancel won in the meantime is not this call's to announce -- + whoever won it already queued one.""" + job = _job() + qs2_outer = mock.MagicMock() + qs2_outer.filter.return_value = _qs([]) + with ( + mock.patch.object(maintenance.AgentKVJob, "objects") as m_objects, + # The guarded UPDATE matched nothing: someone else got there first. + mock.patch.object(maintenance.AgentKVJob, "mark_terminal", return_value=False), + mock.patch.object(maintenance.AgentKVConcurrencyLimiter, "release") as m_rel, + mock.patch.object(maintenance, "dispatch_terminal_webhook") as m_hook, + ): + m_objects.filter.side_effect = [_qs([job]), qs2_outer, _qs([])] + maintenance.run_sweep() + + m_hook.assert_not_called() + # And the slot is not released either -- the winner released its own. + m_rel.assert_not_called() From f9d13ecd99c9dc522433d4acb920148ba42811a3 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Thu, 15 Oct 2026 14:59:50 +0530 Subject: [PATCH 53/54] UN-4232 [REFACTOR] Mount the Agent-KV API under /api/v1 like every other API @ritwik-g on unstract-cloud#1828: "why did we deviate from the /api/v1? Any specific reason? Else we should consider moving this under that prefix and removing these rules." There was no specific reason, so it no longer deviates. `AGENT_KV_PATH_PREFIX` now defaults to `api/v1/agent-kv` instead of `agent-kv`, and the routing rules that existed only to reach a top-level mount come out with it: - the cloud ingress entry, and its repeat in `dev.base.values.yaml` - the traefik terms in `docker/docker-compose.yaml` -- BOTH of them, the backend's `PathPrefix` and the frontend's negation of it That pair is why the move is worth making rather than just tidy. A top-level mount had to be added to the backend rule AND removed from the frontend's negation, in every file that redefines the list; miss either and the SPA answers the API with a 200 and an HTML page, which is not a 404 and shows up in no log. It had already happened twice: once in compose (branch review 2.2) and once on-prem, where all three values files replace `ingress.hosts` and none carried `/agent-kv`. It is NOT under `TENANT_SUBFOLDER_PREFIX` (`api/v1/unstract`): the API resolves its organisation from the Bearer key, not a URL segment. The whitelist entry is still needed -- the views own their auth -- and the startup guard still refuses a prefix that is empty or collides with `PATH_PREFIX` / `API_DEPLOYMENT_PATH_PREFIX`; a descendant like this one was always allowed. Verified by resolving the real URLconf rather than reading it: all four routes resolve under `/api/v1/agent-kv/` and the whitelist entry is present. That mattered, because `api/v1/` is `include`d BEFORE the Agent-KV mount -- Django falls through when no sub-pattern matches, but this proves it does. The traefik test is rewritten to assert the coupling is GONE (no `/agent-kv` term, `/api/v1` present) instead of asserting the term exists, and its companion `test_traefik_excludes_agent_kv_from_the_frontend` is deleted -- the general complement invariant below it already covers the negation list. Docs, both sample.envs and the e2e URL helpers follow. The internal worker API (`/internal/v1/agent-kv/...`) is untouched; so is key management, which was always tenant-scoped. 350 backend and 12 worker-wiring tests pass. Co-Authored-By: Claude Opus 5 --- .../agent_kv/tests/test_path_prefix_guard.py | 2 +- backend/backend/settings/base.py | 11 ++++- backend/sample.env | 2 +- docker/docker-compose.yaml | 4 +- docker/sample.env | 2 +- docs/agent-kv-api.md | 44 ++++++++--------- tests/e2e/agent_kv/conftest.py | 8 +++- workers/tests/test_queue_consumer_wiring.py | 47 +++++++++---------- 8 files changed, 66 insertions(+), 54 deletions(-) diff --git a/backend/agent_kv/tests/test_path_prefix_guard.py b/backend/agent_kv/tests/test_path_prefix_guard.py index 1a99d53ab3..c9e334509f 100644 --- a/backend/agent_kv/tests/test_path_prefix_guard.py +++ b/backend/agent_kv/tests/test_path_prefix_guard.py @@ -81,5 +81,5 @@ def test_the_default_is_safe(): """ from django.conf import settings - assert settings.AGENT_KV_PATH_PREFIX == "agent-kv" + assert settings.AGENT_KV_PATH_PREFIX == "api/v1/agent-kv" assert f"/{settings.AGENT_KV_PATH_PREFIX}" in settings.WHITELISTED_PATHS diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index 93346d7e70..39eac242b2 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -253,8 +253,17 @@ def validate_agent_kv_path_prefix(raw, path_prefix, deployment_prefix): return prefix +# Under PATH_PREFIX, not beside it: `/api/v1/agent-kv/`. It is a product API +# like every other one the platform serves, and sharing the prefix means the +# LB rule that already routes `/api/v1` covers it -- no dedicated ingress +# entry to add, and nothing to keep in step with this value. That coupling +# was a real gap: on-prem values files replace `ingress.hosts` wholesale and +# carried no `/agent-kv`, so the API answered the SPA's HTML there. +# +# It is NOT under TENANT_SUBFOLDER_PREFIX (`api/v1/unstract`), because it +# resolves its organisation from the Bearer key rather than a URL segment. AGENT_KV_PATH_PREFIX = validate_agent_kv_path_prefix( - os.environ.get("AGENT_KV_PATH_PREFIX", "agent-kv"), + os.environ.get("AGENT_KV_PATH_PREFIX", f"{PATH_PREFIX}/agent-kv"), PATH_PREFIX, API_DEPLOYMENT_PATH_PREFIX, ) diff --git a/backend/sample.env b/backend/sample.env index 902e220684..a052f5066f 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -188,7 +188,7 @@ API_DEPLOYMENT_RATE_LIMIT_LOCK_BLOCKING_TIMEOUT=5 # Agent-KV API (backend/backend/settings/base.py) — see docs/agent-kv-api.md # --------------------------------------------------------------------------- -AGENT_KV_PATH_PREFIX=agent-kv # Public URL prefix (whitelisted past tenant middleware) +AGENT_KV_PATH_PREFIX=api/v1/agent-kv # Public URL prefix; shares the /api/v1 ingress rule AGENT_KV_MAX_FILE_SIZE_MB=50 # Submit-time file size cap (MB) AGENT_KV_MAX_PAGES=100 # Pre-OCR page cap for PDFs/images; also the post-OCR cap the engine enforces AGENT_KV_MAX_CALCULATIONS_BYTES=20000 # Byte cap on the optional `calculations` field diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index eedfdf7a39..50c8d8fad4 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -71,7 +71,7 @@ services: - PG_SCHEDULER_ADOPT_PERIODICS=true labels: - traefik.enable=true - - traefik.http.routers.backend.rule=Host(`frontend.unstract.localhost`) && (PathPrefix(`/api/v1`) || PathPrefix(`/deployment`) || PathPrefix(`/public`) || PathPrefix(`/agent-kv`)) + - traefik.http.routers.backend.rule=Host(`frontend.unstract.localhost`) && (PathPrefix(`/api/v1`) || PathPrefix(`/deployment`) || PathPrefix(`/public`)) - traefik.http.services.backend.loadbalancer.server.port=8000 # Celery Flower @@ -113,7 +113,7 @@ services: - UNSTRACT_APPS_VERSION=${VERSION} labels: - traefik.enable=true - - traefik.http.routers.frontend.rule=Host(`frontend.unstract.localhost`) && !PathPrefix(`/api/v1`) && !PathPrefix(`/deployment`) && !PathPrefix(`/public`) && !PathPrefix(`/agent-kv`) + - traefik.http.routers.frontend.rule=Host(`frontend.unstract.localhost`) && !PathPrefix(`/api/v1`) && !PathPrefix(`/deployment`) && !PathPrefix(`/public`) - traefik.http.services.frontend.loadbalancer.server.port=80 platform-service: diff --git a/docker/sample.env b/docker/sample.env index de64df726f..447e69f5e3 100644 --- a/docker/sample.env +++ b/docker/sample.env @@ -123,7 +123,7 @@ LOG_CONSUMER_TASK_TIME_LIMIT=600 # Log processing timeout (10 minutes # `agent_kv` app). Consumed by the Django backend process, not by # docker-compose itself — mirror these into backend/.env (backend/sample.env) # for a real deployment; listed here per the Agent-KV env-sample convention. -AGENT_KV_PATH_PREFIX=agent-kv # Public URL prefix (whitelisted past tenant middleware) +AGENT_KV_PATH_PREFIX=api/v1/agent-kv # Public URL prefix; shares the /api/v1 ingress rule AGENT_KV_MAX_FILE_SIZE_MB=50 # Submit-time file size cap (MB) AGENT_KV_MAX_PAGES=100 # Pre-OCR page cap for PDFs/images AGENT_KV_MAX_CALCULATIONS_BYTES=20000 # Byte cap on the optional `calculations` field diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index 52ce862440..cfba6ad288 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -23,10 +23,10 @@ There are two independent auth schemes on two different URL prefixes: | Surface | Prefix | Auth | |---|---|---| -| Public execution API (submit/status/result/cancel) | `/agent-kv/…` (top-level, not under the tenant subfolder) | `Authorization: Bearer ` — a dedicated per-organization key, validated by the view itself | -| Key management (create/list/rotate/revoke) | `{PATH_PREFIX}/unstract/…/agent-kv/keys/…` (tenant-scoped, alongside Platform API key management) | Session auth, `IsOrganizationAdmin` only | +| Public execution API (submit/status/result/cancel) | `/api/v1/agent-kv/…` (under the platform API prefix, beside the tenant subfolder rather than inside it) | `Authorization: Bearer ` — a dedicated per-organization key, validated by the view itself | +| Key management (create/list/rotate/revoke) | `{PATH_PREFIX}/unstract/…/api/v1/agent-kv/keys/…` (tenant-scoped, alongside Platform API key management) | Session auth, `IsOrganizationAdmin` only | -The execution prefix (`AGENT_KV_PATH_PREFIX`, default `agent-kv`) sits **outside** the +The execution prefix (`AGENT_KV_PATH_PREFIX`, default `api/v1/agent-kv`) sits **outside** the tenant-subfolder/organization-middleware chain — it is added to `WHITELISTED_PATHS` in `backend/backend/settings/base.py` — because a caller authenticates with an API key, not a session/org header. Every view under it therefore owns its own auth via the @@ -86,9 +86,9 @@ Routes (`backend/agent_kv/urls.py`, mounted at `agent-kv/` under `backend/backend/urls_v2.py`): ``` -GET/POST .../agent-kv/keys/ -GET/PATCH/DELETE .../agent-kv/keys// -POST .../agent-kv/keys//rotate/ # replaces `key` with a new UUID +GET/POST .../api/v1/agent-kv/keys/ +GET/PATCH/DELETE .../api/v1/agent-kv/keys// +POST .../api/v1/agent-kv/keys//rotate/ # replaces `key` with a new UUID ``` Key storage follows the same conventions as Platform API key management (D5) — @@ -206,7 +206,7 @@ env-configurable is the raw byte size of the whole `keys` document A schema that fails to compile is rejected with a `400` at submit time — **nothing is billed**. -## 3. Submit — `POST /agent-kv/` (multipart) +## 3. Submit — `POST /api/v1/agent-kv/` (multipart) Copied verbatim from spec §7.1 and cross-checked field-by-field against `backend/agent_kv/execution_serializers.py::SubmitSerializer` — every row below matches @@ -364,7 +364,7 @@ Not exposed (D6): model choice, challenger model, `parallel_pages`, thinking bud { "job_id": "5b6e9b0a-...", "status": "dispatched", - "status_url": "/agent-kv/5b6e9b0a-...", + "status_url": "/api/v1/agent-kv/5b6e9b0a-...", "created_at": "2026-08-28T10:15:00.123456+00:00" } ``` @@ -388,7 +388,7 @@ deployment returns `501` before ever reaching it. ### Example — `table` (the only extractor this deployment accepts) ```bash -curl -X POST https://api.unstract.example/agent-kv/ \ +curl -X POST https://api.unstract.example/api/v1/agent-kv/ \ -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" \ -F "file=@invoice.pdf" \ -F 'extractors=[{ @@ -418,7 +418,7 @@ note above). Note it takes no `adapters` block: the `kv` extractor is env-configured on the operator side. ```bash -curl -X POST https://api.unstract.example/agent-kv/ \ +curl -X POST https://api.unstract.example/api/v1/agent-kv/ \ -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" \ -F "file=@invoice.pdf" \ -F 'extractors=[{ @@ -441,7 +441,7 @@ curl -X POST https://api.unstract.example/agent-kv/ \ -F "webhook_url=https://example.com/hooks/agent-kv" ``` -## 4. Status — `GET /agent-kv/{job_id}` +## 4. Status — `GET /api/v1/agent-kv/{job_id}` Verbose, agent-centric, stage-level (`backend/agent_kv/execution_views.py::_status_document`). **Stage names are per-extractor.** They are wire format, and `qa`/`challenge`/`codegen` @@ -505,11 +505,11 @@ spec's illustrative example, not a drift, since spec §7.2 was explicitly a part sample ("Verbose, agent-centric, stage-level"). ```bash -curl https://api.unstract.example/agent-kv/5b6e9b0a-.../ \ +curl https://api.unstract.example/api/v1/agent-kv/5b6e9b0a-.../ \ -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" ``` -## 5. Result — `GET /agent-kv/{job_id}/result` +## 5. Result — `GET /api/v1/agent-kv/{job_id}/result` Re-readable until `expires_at` (D11), plus explicit `DELETE`. @@ -563,7 +563,7 @@ carries the inner error details. an engine result). Document the code: no `timing` on failure/cancellation bodies. ```bash -curl https://api.unstract.example/agent-kv/5b6e9b0a-.../result \ +curl https://api.unstract.example/api/v1/agent-kv/5b6e9b0a-.../result \ -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" ``` @@ -698,7 +698,7 @@ from `timing`, which is an internal structure rather than a contract. There is * > two settings of one option: a client that sets the flag must read `tables.rows` > instead of iterating `tables`. -## 6. Cancel — `POST /agent-kv/{job_id}/cancel` +## 6. Cancel — `POST /api/v1/agent-kv/{job_id}/cancel` Defined precisely per spec §7.4: no task-revocation machinery exists anywhere in the platform, so cancel flips the job row to `CANCELLED` via the same guarded @@ -775,7 +775,7 @@ so a **follow-up notification** carrying `result_available: true` is delivered when this happens — see [§8](#8-webhook-delivery). > **This was reverted three times before it shipped, and the reason is worth -> knowing before you touch it.** `DELETE /agent-kv/{job_id}/` guarantees that a +> knowing before you touch it.** `DELETE /api/v1/agent-kv/{job_id}/` guarantees that a > purged job cannot be resurrected, and it used to guarantee that *by relying on* > the late finalize deleting its own orphaned write. Keeping the result removes > exactly that cleanup, and every attempt to rebuild the guarantee from the job's @@ -814,11 +814,11 @@ when this happens — see [§8](#8-webhook-delivery). assertion and had never been run.* ```bash -curl -X POST https://api.unstract.example/agent-kv/5b6e9b0a-.../cancel \ +curl -X POST https://api.unstract.example/api/v1/agent-kv/5b6e9b0a-.../cancel \ -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" ``` -## 7. Delete — `DELETE /agent-kv/{job_id}` +## 7. Delete — `DELETE /api/v1/agent-kv/{job_id}` Deletes the result and any residual staged input immediately, blanks the refs whose objects are confirmed gone, and marks the job `files_purged` (`204`, no body). The job @@ -848,7 +848,7 @@ A few details that matter if you are changing this endpoint: along with everything else, and one attempted afterwards is refused outright. ```bash -curl -X DELETE https://api.unstract.example/agent-kv/5b6e9b0a-.../ \ +curl -X DELETE https://api.unstract.example/api/v1/agent-kv/5b6e9b0a-.../ \ -H "Authorization: Bearer 5c9e2c9e-1234-4a5b-9c6d-abcdef012345" ``` @@ -973,7 +973,7 @@ production. success callback that *does* store a result there ([§6](#6-cancel--post-agent-kvjob_idcancel)) leaves the input alone. A cancelled job's staged input is therefore cleaned up the ordinary way: by the TTL sweep once - `expires_at` passes, or by an explicit `DELETE /agent-kv/{job_id}`. + `expires_at` passes, or by an explicit `DELETE /api/v1/agent-kv/{job_id}`. - **Result retention**: `AGENT_KV_RESULT_TTL_DAYS` (default **7** days, D10's engineering default) — results are re-readable until then, then swept by the internal `ttl-cleanup` endpoint ([§6](#11-deploy-checklist)), which blanks @@ -1018,7 +1018,7 @@ if not get_plugin("agent_kv"): `get_plugin(...)` (`backend/plugins/__init__.py`) returns an empty dict — falsy — when no `plugins/agent_kv/` package is installed, which is the OSS-only case by design (the plugin is a cloud deliverable, spec §5.1/§11: "OSS scaffold merges dark"). Every -`POST /agent-kv/` on an OSS-only deployment therefore returns: +`POST /api/v1/agent-kv/` on an OSS-only deployment therefore returns: ```json {"detail": "agent-kv engine not available on this deployment"} @@ -1442,7 +1442,7 @@ with the default shown: | Variable | Default | Meaning | |---|---|---| -| `AGENT_KV_PATH_PREFIX` | `agent-kv` | Top-level public URL prefix (whitelisted past tenant middleware). | +| `AGENT_KV_PATH_PREFIX` | `api/v1/agent-kv` | Top-level public URL prefix (whitelisted past tenant middleware). | | `AGENT_KV_MAX_FILE_SIZE_MB` | `50` | Submit-time file size cap. | | `AGENT_KV_ABANDONED_SLOT_GRACE_SECONDS` | `3600` | How long after CANCELLATION a cancelled job's executor is presumed dead, so the sweep's phase 3 may release the concurrency slot it still holds. Must stay **below** `rate_limiter.SLOT_TTL_SECONDS` (6 h) or the window is empty and the phase silently releases nothing. | | `AGENT_KV_MAX_PAGES` | `100` | Pre-OCR cap on the pages of the DOCUMENT for PDFs — checked against `pages_total`, **not** against the `page_start`/`page_end` range (§6.1), because the whole file is OCR'd and billed whatever the range. The cloud worker refuses on the same quantity. Excel has no pre-OCR page concept, so it takes the engine's post-OCR virtual-page cap instead; images are not accepted. | diff --git a/tests/e2e/agent_kv/conftest.py b/tests/e2e/agent_kv/conftest.py index ccd742bf12..f9dd5e6d3c 100644 --- a/tests/e2e/agent_kv/conftest.py +++ b/tests/e2e/agent_kv/conftest.py @@ -88,12 +88,16 @@ class AgentKVAuth: def headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self.key}"} + #: Mounted under the platform's own API prefix, so it rides the `/api/v1` + #: route every deployment already has rather than needing its own. + PREFIX = "api/v1/agent-kv" + @property def exec_url(self) -> str: - return f"{self.base}/agent-kv/" + return f"{self.base}/{self.PREFIX}/" def job_url(self, job_id: str, suffix: str = "") -> str: - return f"{self.base}/agent-kv/{job_id}{suffix}" + return f"{self.base}/{self.PREFIX}/{job_id}{suffix}" @pytest.fixture(scope="session") diff --git a/workers/tests/test_queue_consumer_wiring.py b/workers/tests/test_queue_consumer_wiring.py index 14fc562cc7..ba92b21676 100644 --- a/workers/tests/test_queue_consumer_wiring.py +++ b/workers/tests/test_queue_consumer_wiring.py @@ -263,18 +263,22 @@ def test_run_worker_pg_ide_callback_role_drains_the_callback_queue(): # is up, the route looks configured, and the request lands somewhere that # cannot serve it. # -# `/agent-kv/` is mounted by `backend/backend/base_urls.py`, but traefik's -# backend rule matched only `/api/v1`, `/deployment` and `/public`, and the -# frontend rule is the negation of exactly those -- so every Agent-KV request -# through the compose stack was served by the SPA's nginx and never reached -# Django. The e2e lane did not catch it because it talks to -# `UNSTRACT_BACKEND_URL` (port 8000) directly, bypassing traefik entirely. +# The Agent-KV API is mounted under `AGENT_KV_PATH_PREFIX`, which defaults to +# `api/v1/agent-kv` -- inside the `/api/v1` prefix traefik already routes to +# the backend. It used to sit at `/agent-kv`, needing its own term in BOTH the +# backend rule and the frontend's negation of it; miss either and every +# request was served by the SPA's nginx and never reached Django. The e2e lane +# could not catch that, because it talks to `UNSTRACT_BACKEND_URL` directly +# and bypasses traefik. # -# Reported as 2.2 in the branch review. +# Sharing the prefix is what removes the failure mode rather than guarding it, +# so this asserts the coupling is gone: the prefix must stay under a path +# traefik already sends to the backend. +# +# Reported as 2.2 in the branch review; the prefix move came from review of +# unstract-cloud#1828. # -------------------------------------------------------------------------- -AGENT_KV_PREFIX = "/agent-kv" - def _router_rule(compose_path: Path, router: str) -> str: svc_labels = [] @@ -287,23 +291,18 @@ def _router_rule(compose_path: Path, router: str) -> str: pytest.fail(f"no traefik rule for router {router!r} in {compose_path.name}") -def test_traefik_routes_agent_kv_to_the_backend(): +def test_agent_kv_rides_a_prefix_traefik_already_routes_to_the_backend(): + """No dedicated `/agent-kv` term, because the prefix lives under one that + is already routed. If the prefix is ever moved back outside `/api/v1`, + both the backend rule and the frontend's negation need a term again -- + and missing the second one is silent. + """ rule = _router_rule(DEV_COMPOSE, "backend") - assert f"PathPrefix(`{AGENT_KV_PREFIX}`)" in rule, ( - "traefik does not route /agent-kv to the backend; the request is served " - "by the frontend SPA and never reaches Django" - ) - -def test_traefik_excludes_agent_kv_from_the_frontend(): - """The frontend rule is a negation list, so a prefix missing from it is - claimed by the SPA even once the backend also matches -- whichever router - wins, one of them is wrong. - """ - rule = _router_rule(DEV_COMPOSE, "frontend") - assert f"!PathPrefix(`{AGENT_KV_PREFIX}`)" in rule, ( - "the frontend router still claims /agent-kv; it must be excluded " - "explicitly, exactly as /api/v1, /deployment and /public are" + assert "PathPrefix(`/api/v1`)" in rule, rule + assert "/agent-kv" not in rule, ( + "a dedicated /agent-kv term is back in the traefik rule; either the " + "prefix moved out of /api/v1, or this term is now dead weight" ) From 6e55841f57a0971e1c92f401ce8afd6a4e9e17e9 Mon Sep 17 00:00:00 2001 From: vishnuszipstack Date: Thu, 15 Oct 2026 15:08:53 +0530 Subject: [PATCH 54/54] UN-4232 [FIX] Keep the /api/v1 prefix off the key-management URLs in the docs Greptile on #2317: the prefix change also rewrote the key-management paths, which did not move. An admin following those examples would get a 404. My own regex did it. The pattern required the character before `/agent-kv/` to be neither a word character nor a slash, to avoid touching `/internal/v1/agent-kv/...` -- but the docs write the management routes as `.../agent-kv/keys/`, and `.` satisfies that, so all three code-block lines and the authentication table were rewritten too. The two surfaces are genuinely different mounts, which is the thing to hold on to: execution base_urls.py {AGENT_KV_PATH_PREFIX}/ -> /api/v1/agent-kv/ key management urls_v2.py agent-kv/ under the -> /api/v1/unstract/ tenant prefix {org}/agent-kv/keys/ Only the first moved. Restored the four management paths and confirmed the split by resolving the real URLconf rather than re-reading the diff. Co-Authored-By: Claude Opus 5 --- docs/agent-kv-api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/agent-kv-api.md b/docs/agent-kv-api.md index cfba6ad288..6e80e2c5be 100644 --- a/docs/agent-kv-api.md +++ b/docs/agent-kv-api.md @@ -24,7 +24,7 @@ There are two independent auth schemes on two different URL prefixes: | Surface | Prefix | Auth | |---|---|---| | Public execution API (submit/status/result/cancel) | `/api/v1/agent-kv/…` (under the platform API prefix, beside the tenant subfolder rather than inside it) | `Authorization: Bearer ` — a dedicated per-organization key, validated by the view itself | -| Key management (create/list/rotate/revoke) | `{PATH_PREFIX}/unstract/…/api/v1/agent-kv/keys/…` (tenant-scoped, alongside Platform API key management) | Session auth, `IsOrganizationAdmin` only | +| Key management (create/list/rotate/revoke) | `{PATH_PREFIX}/unstract/…/agent-kv/keys/…` (tenant-scoped, alongside Platform API key management) | Session auth, `IsOrganizationAdmin` only | The execution prefix (`AGENT_KV_PATH_PREFIX`, default `api/v1/agent-kv`) sits **outside** the tenant-subfolder/organization-middleware chain — it is added to `WHITELISTED_PATHS` in @@ -86,9 +86,9 @@ Routes (`backend/agent_kv/urls.py`, mounted at `agent-kv/` under `backend/backend/urls_v2.py`): ``` -GET/POST .../api/v1/agent-kv/keys/ -GET/PATCH/DELETE .../api/v1/agent-kv/keys// -POST .../api/v1/agent-kv/keys//rotate/ # replaces `key` with a new UUID +GET/POST .../agent-kv/keys/ +GET/PATCH/DELETE .../agent-kv/keys// +POST .../agent-kv/keys//rotate/ # replaces `key` with a new UUID ``` Key storage follows the same conventions as Platform API key management (D5) —