From bfb2c4dd838b3802784c52a1a22d0c646cb7ae17 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:27:32 +0530 Subject: [PATCH 01/65] =?UTF-8?q?UN-3534=20[FEAT]=20PG=20Queue=20Phase=208?= =?UTF-8?q?a=20=E2=80=94=20queue-transport=20routing=20gate=20+=20scaffold?= =?UTF-8?q?=20(#2033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3534 [FEAT] PG Queue Phase 8a — queue-transport routing gate + scaffold Add the Strangler-Fig routing seam that lets PG Queue (PGMQ) coexist with Celery so task types can be migrated one at a time. Scaffold only: the PG branch is a Celery-routing stub (no PG consumer exists yet), so this is zero-behaviour-change by construction. - queue_backend/routing.py: QueueBackend{CELERY,PG} + select_backend(task_name) reading the WORKER_PG_QUEUE_ENABLED_TASKS allow-list (default empty -> all Celery). Tolerant CSV parsing; never raises. - dispatch(): consults select_backend(); PG-selected tasks are logged but still dispatched via Celery. The send_task call sits outside the PG branch so the wire is byte-identical regardless of the routing decision. - queue_backend/pg_queue/: scaffold subpackage. PGMQ is a core transport substrate, so it lives in the seam beside dispatch/routing/barrier, not under the git-ignored plugins/ cloud overlay. - sample.env: documents the flag (default-safe, OSS-friendly, no Flipt server). - tests: 12 routing tests incl. the byte-identical-dispatch characterisation pinning the inert-scaffold invariant. Barrier axis untouched (WORKER_BARRIER_BACKEND stays chord). Per-org routing intentionally deferred to the rollout phase. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3534 [FEAT] Address Phase 8a review feedback - test seam: drop stale WORKER_PG_QUEUE_ENABLED_ORGS reference — the org axis was removed, that flag never existed [must-fix] - observability: routing log DEBUG -> INFO so a cutover survives a default log config; log-once per task name bounds volume. Log the configured allow-list once per process so a typo'd task name is eyeballable at boot even when it never matches a real dispatch [important] - tests: pin the routing branch with caplog assertions (PG -> log fires, Celery -> no log, bounded to once) so the inert gate can't be silently deleted; assert allow-list logging too [important — closes test gap] - QueueBackend: document the is-not-== discipline (StrEnum makes a typo'd "== cellery" a silent False) [suggestion] - routing: drop dead _parse_allow_list(env_var) param, read the constant directly; one-pass strip; test imports the constant (single source of truth) [nits] - pg_queue docstring: clarify plugins/ subdirs are git-ignored while the dir itself is tracked; soften volatile labs branch/section/filename references [nits] Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3534 [DOCS] Note migration-coherence constraint in routing gate Document that the per-task allow-list may only split independent/leaf tasks across substrates. The coupled execution pipeline (async_execute_bin -> file processing -> callback, with the barrier fan-in) must run a single execution entirely on one transport — its migration unit is the execution, not the task. The next phase resolves transport once at kickoff and carries it in ExecutionContext; select_backend then honours that carried marker over the per-task env. Until then, only leaf tasks should be enabled here. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3534 [DOCS] Fix sample.env example to a leaf task (not pipeline) async_execute_bin is the pipeline kickoff — exactly the task the coherence note says must NOT be split per-task. Switch the example to a leaf task (send_webhook_notification) and warn against listing coupled pipeline tasks until ExecutionContext carries the transport choice. Addresses Greptile review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- workers/queue_backend/__init__.py | 12 ++ workers/queue_backend/dispatch.py | 31 ++++ workers/queue_backend/pg_queue/__init__.py | 24 +++ workers/queue_backend/routing.py | 134 ++++++++++++++ workers/sample.env | 28 +++ workers/tests/test_queue_backend_seam.py | 9 +- workers/tests/test_routing.py | 198 +++++++++++++++++++++ 7 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 workers/queue_backend/pg_queue/__init__.py create mode 100644 workers/queue_backend/routing.py create mode 100644 workers/tests/test_routing.py diff --git a/workers/queue_backend/__init__.py b/workers/queue_backend/__init__.py index 3fcb1bab62..c4310f5eff 100644 --- a/workers/queue_backend/__init__.py +++ b/workers/queue_backend/__init__.py @@ -23,6 +23,15 @@ Unknown values raise — operators get a loud error at module import time rather than silently falling back, which would mask a typo'd flag in a production env. + +**Queue-transport routing (separate axis).** ``dispatch()`` consults +:func:`~queue_backend.routing.select_backend`, which reads the +PG-queue routing table (``WORKER_PG_QUEUE_ENABLED_TASKS``) to decide +Celery-vs-PG per task. This is orthogonal to the barrier choice above: +the barrier is *how a batch's fan-in fires the callback*; the transport +is *how messages travel*. Both default to Celery. The routing table is +a scaffold today — PG-selected tasks still ride Celery (no PG consumer +yet) — so it is observable but inert. """ import os @@ -37,6 +46,7 @@ barrier_abort, barrier_decr_and_check, ) +from .routing import QueueBackend, select_backend __all__ = [ "Barrier", @@ -44,11 +54,13 @@ "BarrierHandle", "CeleryChordBarrier", "FairnessKey", + "QueueBackend", "RedisDecrBarrier", "barrier_abort", "barrier_decr_and_check", "dispatch", "get_barrier", + "select_backend", "worker_task", ] diff --git a/workers/queue_backend/dispatch.py b/workers/queue_backend/dispatch.py index cf8d2c274e..e986ecb6f4 100644 --- a/workers/queue_backend/dispatch.py +++ b/workers/queue_backend/dispatch.py @@ -7,6 +7,7 @@ from __future__ import annotations +import logging from collections.abc import Mapping, Sequence from typing import Any @@ -14,6 +15,14 @@ from .fairness import FairnessKey from .handle import DispatchHandle +from .routing import QueueBackend, select_backend + +logger = logging.getLogger(__name__) + +# Task names already logged as PG-routed in this process. Bounds the +# routing log to once per task name (per prefork child) so an opted-in +# high-throughput task doesn't log on every dispatch. +_pg_routing_logged: set[str] = set() def dispatch( @@ -28,7 +37,29 @@ def dispatch( ``fairness`` is attached as the ``x-fairness-key`` header (not in kwargs). Pass ``None`` for non-workflow worker tasks. + + The transport is chosen by :func:`select_backend` from the PG-queue + routing table (``WORKER_PG_QUEUE_ENABLED_TASKS``). In this phase the + table is a scaffold: PG-selected tasks are *logged* but still + dispatched via Celery, because no PG consumer exists yet. The + ``QueueBackend.PG`` branch below is the seam where the real PG + enqueue lands in a later phase — keeping the ``send_task`` call + outside it guarantees today's wire is byte-identical regardless of + the routing decision. """ + if ( + select_backend(task_name) is QueueBackend.PG + and task_name not in _pg_routing_logged + ): + # INFO (not DEBUG) so the cutover is visible under a default log + # config; log-once per task name keeps the volume bounded. + _pg_routing_logged.add(task_name) + logger.info( + "PG-queue routing selected for task=%r; dispatching via " + "Celery (scaffold — no PG consumer yet)", + task_name, + ) + headers = fairness.as_header() if fairness is not None else None return current_app.send_task( task_name, diff --git a/workers/queue_backend/pg_queue/__init__.py b/workers/queue_backend/pg_queue/__init__.py new file mode 100644 index 0000000000..a85622cfe8 --- /dev/null +++ b/workers/queue_backend/pg_queue/__init__.py @@ -0,0 +1,24 @@ +"""PG Queue (PGMQ) transport substrate — scaffold. + +Reserved home for the PostgreSQL-backed queue substrate (PGMQ + +``SKIP LOCKED``) that will run alongside Celery during the Strangler-Fig +migration. PGMQ is a *core* worker transport — the intended primary +backend — so it lives inside the queue-backend seam next to its sibling +substrates (``dispatch``, ``routing``, ``barrier``, ``redis_barrier``), +**not** under ``workers/plugins/``, whose plugin *implementation +subdirectories* are the git-ignored overlay copied in at build time (the +directory itself — ``__init__.py``, ``plugin_manager.py`` — is tracked). + +A subpackage (rather than a single module like the barriers) because the +real implementation will likely span several modules (config, consumer +poll loop, orchestrator) — exact layout TBD. + +Empty by design in this phase. Routing decisions are made by +:func:`queue_backend.routing.select_backend`; until a consumer exists +here, PG-selected dispatches still ride Celery (see +``queue_backend.dispatch``). + +Design reference: the PG Queue implementation guide in the labs repo +(``workflow-execution-architecture``). Branch and section pointers move, +so they're tracked on UN-3534 / the PR rather than baked in here. +""" diff --git a/workers/queue_backend/routing.py b/workers/queue_backend/routing.py new file mode 100644 index 0000000000..abc43e8029 --- /dev/null +++ b/workers/queue_backend/routing.py @@ -0,0 +1,134 @@ +"""Queue-transport routing gate (Strangler-Fig). + +Decides whether a given dispatch should ride the **Celery** transport +(today's only real path) or the future **PG Queue** (PGMQ) transport, +based on a per-task-type opt-in allow-list read from env: + +- ``WORKER_PG_QUEUE_ENABLED_TASKS`` — comma-separated task names routed + to PG. Empty / unset → everything routes to Celery. + +**Why an allow-list, not a global boolean.** A single +``WORKER_QUEUE_BACKEND=pg`` switch would move *all* traffic at once — +the big-bang migration the PG Queue rollout explicitly forbids. The +allow-list lets an operator migrate one task type at a time +(drain-and-cutover) and roll back instantly by removing an entry. + +(Per-org granularity is intentionally out of scope here. When tenant-level +canarying is needed it slots in behind :func:`select_backend` as a small +additive change — no call site touches the routing decision directly.) + +**Migration coherence — what the allow-list may and may not split.** +Per-task routing is the right granularity for *independent / leaf* tasks +(e.g. ``process_log_history``, ``send_webhook_notification``): no chain, +no barrier, so one can ride PG while the rest stay on Celery — these are +the safe first migration candidates. It is **not** valid to split the +*coupled execution pipeline* (``async_execute_bin`` → file processing → +callback, with the barrier fan-in) across substrates: a chain hands off +stage→stage and the barrier coordinates a batch, so a single execution +must run **entirely on one transport**. The migration unit for the +pipeline is therefore the *execution*, not the task — the next phase will +resolve the transport once at kickoff and carry it in ``ExecutionContext`` +(like the fairness key), and downstream dispatches will honour that +carried choice over the per-task env. ``select_backend`` then becomes: +"if the dispatch carries an execution-transport marker, use it; otherwise +consult this allow-list." Until then, only enable *leaf* tasks here. + +**Scaffold posture.** This module only makes the routing *decision*. +In the current phase there is no PG consumer, so ``dispatch()`` still +sends PG-selected tasks via Celery (the decision is observable in logs +but inert). ``select_backend()`` is the seam where the real PG dispatch +lands in a later phase. + +**Observability.** Because the gate is silent-by-construction (a +misrouted task still runs on Celery), the only signals are logs, emitted +at INFO so they survive a default log config: the configured allow-list +is logged once per process (:func:`_log_allow_list_once`, so a typo is +visible even if it never matches a real task), and the first time each +task is routed to PG (``dispatch()``, log-once per task name). + +**Fail-safe parsing.** Unlike ``get_barrier()`` (which raises on an +unrecognised value, because a typo'd substrate must not silently fall +back), the routing table is a membership set with no "invalid value" +concept: blanks and stray whitespace are simply dropped. Malformed or +empty config can only ever resolve to the safe ``CELERY`` default — +``select_backend()`` never raises. +""" + +from __future__ import annotations + +import logging +import os +from enum import StrEnum + +logger = logging.getLogger(__name__) + +# Env var name kept as a module literal (mirrors how the sibling +# ``WORKER_BARRIER_BACKEND`` flag is read in ``queue_backend.__init__``) +# so the queue-backend seam stays self-contained. +_ENABLED_TASKS_ENV_VAR = "WORKER_PG_QUEUE_ENABLED_TASKS" + +# One-shot guard so the configured allow-list is logged once per process +# (see ``_log_allow_list_once``). Module-level → per prefork child. +_allow_list_logged = False + + +class QueueBackend(StrEnum): + """Transport a dispatch is routed to. + + ``StrEnum`` (3.11+) is inherited for symmetry with ``BarrierBackend``, + but unlike that enum this one is never read from / written to env — + only the task-name allow-list is. So callers MUST compare by identity + (``backend is QueueBackend.PG``), never ``== "pg"``: ``StrEnum`` makes a + typo'd ``== "cellery"`` a silent ``False`` rather than an error. + """ + + CELERY = "celery" + PG = "pg" + + +def _parse_allow_list() -> frozenset[str]: + """Parse ``WORKER_PG_QUEUE_ENABLED_TASKS`` into a set of task names. + + Trims surrounding whitespace per entry and drops blanks, so + ``"a, b ,, c"`` → ``{"a", "b", "c"}`` and ``""`` / unset → ``frozenset()``. + Read at call time (not import) so test harnesses that + ``monkeypatch.setenv`` flip the table per-test without a reload. + """ + stripped = ( + entry.strip() for entry in os.getenv(_ENABLED_TASKS_ENV_VAR, "").split(",") + ) + return frozenset(entry for entry in stripped if entry) + + +def _log_allow_list_once(allow_list: frozenset[str]) -> None: + """Log the configured allow-list once per process, at INFO. + + Makes a misconfiguration eyeballable at boot: a typo'd task name + (``async_execute_bni``) silently routes everything to Celery, but it + still shows up here so an operator can spot it. Only fires for a + *non-empty* allow-list — the default (feature off) stays silent so + the scaffold is truly inert when unused. + """ + global _allow_list_logged + if _allow_list_logged or not allow_list: + return + _allow_list_logged = True + logger.info( + "PG-queue routing enabled for tasks: %s (all others → Celery)", + sorted(allow_list), + ) + + +def select_backend(task_name: str) -> QueueBackend: + """Return the transport a dispatch should ride. + + ``PG`` if ``task_name`` is in ``WORKER_PG_QUEUE_ENABLED_TASKS``; + otherwise ``CELERY``. Empty / unset allow-list → always ``CELERY``. + + Never raises — the worst case is the safe ``CELERY`` default. + """ + allow_list = _parse_allow_list() + _log_allow_list_once(allow_list) + if task_name in allow_list: + return QueueBackend.PG + return QueueBackend.CELERY diff --git a/workers/sample.env b/workers/sample.env index 4764fb0c5a..6e9aaf2d2f 100644 --- a/workers/sample.env +++ b/workers/sample.env @@ -131,6 +131,34 @@ WORKER_BARRIER_BACKEND=chord # workflows can plausibly span >6h. WORKER_BARRIER_KEY_TTL_SECONDS=21600 +# ============================================================================= +# PG Queue Routing (Strangler-Fig) +# ============================================================================= +# Selects the queue *transport* per task — Celery (default) or PG Queue +# (PGMQ). Separate axis from the barrier above: the barrier is how a +# batch's fan-in fires the callback; this is how messages travel. +# +# A comma-separated allow-list drives a gradual, per-task-type migration +# (NOT a global on/off switch). A task routes to PG if its name is +# listed. Unset / empty (the default) → everything routes to Celery, +# exactly as today. +# +# Scaffold note: in this release there is no PG consumer yet, so +# PG-selected tasks are logged but still dispatched via Celery. Setting +# this flag is therefore safe to test routing decisions locally with +# zero behaviour change. No Flipt server required. +# +# Roll back instantly by removing an entry; flips take effect on worker +# restart (same posture as every other WORKER_* env). +# +# Only list *leaf* tasks dispatched via queue_backend.dispatch() — i.e. no +# downstream chain or barrier fan-in (e.g. send_webhook_notification). Do +# NOT list coupled pipeline tasks like async_execute_bin here: one execution +# must run wholly on one transport, so the pipeline migrates per-execution +# (via ExecutionContext) in a later phase, not per-task. See the routing.py +# module docstring for the full constraint. +# WORKER_PG_QUEUE_ENABLED_TASKS=send_webhook_notification + # Database URL (for fallback usage) DATABASE_URL=postgresql://unstract_dev:unstract_pass@unstract-db:5432/unstract_db diff --git a/workers/tests/test_queue_backend_seam.py b/workers/tests/test_queue_backend_seam.py index 8cd827cc10..8112e95d49 100644 --- a/workers/tests/test_queue_backend_seam.py +++ b/workers/tests/test_queue_backend_seam.py @@ -193,9 +193,9 @@ def some_function(): def test_worker_task_matches_shared_task_registration(self): """A function decorated with @worker_task is the same kind of object as one decorated with @shared_task — same registration semantics, same - invocation interface.""" + invocation interface. + """ from celery import shared_task - from queue_backend import worker_task @worker_task(name="queue_backend_test.via_seam") @@ -280,17 +280,22 @@ def test_all_exports(self): # (registered as a Celery task on import) + the BarrierBackend # enum + the get_barrier factory that the WORKER_BARRIER_BACKEND # env flag drives. + # Phase 8a adds QueueBackend + select_backend — the queue-transport + # routing gate that the WORKER_PG_QUEUE_ENABLED_TASKS allow-list + # drives. assert set(queue_backend.__all__) == { "Barrier", "BarrierBackend", "BarrierHandle", "CeleryChordBarrier", "FairnessKey", + "QueueBackend", "RedisDecrBarrier", "barrier_abort", "barrier_decr_and_check", "dispatch", "get_barrier", + "select_backend", "worker_task", } diff --git a/workers/tests/test_routing.py b/workers/tests/test_routing.py new file mode 100644 index 0000000000..f8b55473b4 --- /dev/null +++ b/workers/tests/test_routing.py @@ -0,0 +1,198 @@ +"""Tests for the PG-queue routing gate (``queue_backend.routing``). + +Three layers: + +1. **select_backend()** resolves the per-task allow-list correctly, + defaults to ``CELERY`` when unset, and never raises on malformed + input. + +2. **Observability** — the configured allow-list is logged once, and a + PG cutover emits a log. These are the gate's only observable effect, + so they're asserted explicitly (a future "the gate is inert, delete + it" refactor must fail a test, not pass silently). + +3. **dispatch() is inert under routing** — the ``current_app.send_task`` + call is byte-identical whether the routing table selects ``CELERY`` + or ``PG``. +""" + +from __future__ import annotations + +import importlib +import logging +from unittest.mock import patch + +import pytest +from queue_backend import QueueBackend, dispatch, select_backend +from queue_backend import routing as routing_mod +from queue_backend.fairness import FairnessKey, WorkloadType +from queue_backend.routing import _ENABLED_TASKS_ENV_VAR as ENABLED_TASKS_ENV + +# ``queue_backend.__init__`` binds ``dispatch`` to the *function*, shadowing +# the submodule attribute — import the module explicitly to reach its globals. +dispatch_mod = importlib.import_module("queue_backend.dispatch") + + +@pytest.fixture(autouse=True) +def _reset_routing_state(monkeypatch): + """Empty allow-list + cleared log-once guards before each test. + + The allow-list-logged flag and the per-task routing-logged set are + process-global one-shot guards; reset them so caplog assertions are + deterministic regardless of test order. + """ + monkeypatch.delenv(ENABLED_TASKS_ENV, raising=False) + routing_mod._allow_list_logged = False + dispatch_mod._pg_routing_logged.clear() + + +# --- select_backend() resolution --- + + +class TestSelectBackend: + def test_empty_table_routes_to_celery(self): + assert select_backend("any_task") is QueueBackend.CELERY + + def test_task_opted_in_routes_to_pg(self, monkeypatch): + monkeypatch.setenv(ENABLED_TASKS_ENV, "process_log_history") + assert select_backend("process_log_history") is QueueBackend.PG + + def test_task_not_opted_in_routes_to_celery(self, monkeypatch): + monkeypatch.setenv(ENABLED_TASKS_ENV, "process_log_history") + assert select_backend("some_other_task") is QueueBackend.CELERY + + def test_multiple_entries_membership(self, monkeypatch): + monkeypatch.setenv(ENABLED_TASKS_ENV, "a,b,c") + assert select_backend("b") is QueueBackend.PG + assert select_backend("d") is QueueBackend.CELERY + + +class TestSelectBackendTolerantParsing: + """Malformed / whitespace input resolves to the safe default, never raises.""" + + def test_whitespace_around_entries_trimmed(self, monkeypatch): + monkeypatch.setenv(ENABLED_TASKS_ENV, " a , b , c ") + assert select_backend("b") is QueueBackend.PG + + def test_empty_and_blank_segments_ignored(self, monkeypatch): + monkeypatch.setenv(ENABLED_TASKS_ENV, "a,, ,b") + assert select_backend("a") is QueueBackend.PG + # A blank segment must not become a matchable empty-string entry. + assert select_backend("") is QueueBackend.CELERY + + def test_empty_string_env_routes_to_celery(self, monkeypatch): + monkeypatch.setenv(ENABLED_TASKS_ENV, " ") + assert select_backend("any_task") is QueueBackend.CELERY + + def test_does_not_raise_on_any_input(self, monkeypatch): + for value in ("", " ", ",,,", "a,b", "\t\n", ",a,"): + monkeypatch.setenv(ENABLED_TASKS_ENV, value) + # Must resolve to an enum member, never raise. + assert select_backend("a") in (QueueBackend.CELERY, QueueBackend.PG) + + +class TestQueueBackendEnum: + def test_string_values(self): + assert QueueBackend.CELERY == "celery" + assert QueueBackend.PG == "pg" + + def test_members(self): + assert {b.value for b in QueueBackend} == {"celery", "pg"} + + +# --- Observability (the gate's only observable effect) --- + + +class TestObservability: + def test_allow_list_logged_once_when_configured(self, monkeypatch, caplog): + monkeypatch.setenv(ENABLED_TASKS_ENV, "async_execute_bin") + with caplog.at_level(logging.INFO, logger="queue_backend.routing"): + select_backend("x") + select_backend("y") # second call must NOT re-log + hits = [r for r in caplog.records if "PG-queue routing enabled" in r.getMessage()] + assert len(hits) == 1 + assert "async_execute_bin" in hits[0].getMessage() + + def test_empty_allow_list_logs_nothing(self, caplog): + """Default (feature off) stays silent — truly inert.""" + with caplog.at_level(logging.INFO, logger="queue_backend.routing"): + select_backend("x") + assert "PG-queue routing enabled" not in caplog.text + + def test_pg_selection_emits_routing_log(self, monkeypatch, caplog): + """Pins the dispatch() routing branch: deleting it must fail here.""" + monkeypatch.setenv(ENABLED_TASKS_ENV, "t1") + with ( + patch("queue_backend.dispatch.current_app"), + caplog.at_level(logging.INFO, logger="queue_backend.dispatch"), + ): + dispatch("t1") + assert "PG-queue routing selected" in caplog.text + + def test_celery_selection_emits_no_routing_log(self, caplog): + """Negative case — guards against the gate being made unconditional.""" + with ( + patch("queue_backend.dispatch.current_app"), + caplog.at_level(logging.INFO, logger="queue_backend.dispatch"), + ): + dispatch("t1") # empty allow-list → CELERY + assert "PG-queue routing selected" not in caplog.text + + def test_routing_log_bounded_to_once_per_task(self, monkeypatch, caplog): + monkeypatch.setenv(ENABLED_TASKS_ENV, "t1") + with ( + patch("queue_backend.dispatch.current_app"), + caplog.at_level(logging.INFO, logger="queue_backend.dispatch"), + ): + dispatch("t1") + dispatch("t1") + dispatch("t1") + hits = [r for r in caplog.records if "PG-queue routing selected" in r.getMessage()] + assert len(hits) == 1 + + +# --- dispatch() is inert under routing (the scaffold invariant) --- + + +class TestDispatchByteIdenticalRegardlessOfRouting: + """``dispatch()`` produces the same send_task call whether routed PG or Celery.""" + + def _capture_send_task(self, task_name, fairness): + with patch("queue_backend.dispatch.current_app") as mock_app: + dispatch( + task_name, + args=["a", 1], + kwargs={"k": "v"}, + queue="general", + fairness=fairness, + ) + return mock_app.send_task.call_args + + def test_pg_selection_does_not_change_the_wire(self, monkeypatch): + fairness = FairnessKey(org_id="org-1", workload_type=WorkloadType.API) + + # Celery path (empty table). + celery_call = self._capture_send_task("t1", fairness) + + # PG path (task opted in) — same inputs. + monkeypatch.setenv(ENABLED_TASKS_ENV, "t1") + pg_call = self._capture_send_task("t1", fairness) + + assert celery_call == pg_call + assert pg_call.args[0] == "t1" + assert pg_call.kwargs["args"] == ["a", 1] + assert pg_call.kwargs["kwargs"] == {"k": "v"} + assert pg_call.kwargs["queue"] == "general" + + def test_dispatch_without_fairness_still_routes_by_task(self, monkeypatch): + """Routing keys on task name only; fairness is irrelevant to the decision.""" + monkeypatch.setenv(ENABLED_TASKS_ENV, "bare_task") + with patch("queue_backend.dispatch.current_app") as mock_app: + dispatch("bare_task") + # Still dispatched (via Celery), header is None (no fairness). + assert mock_app.send_task.call_args.args[0] == "bare_task" + assert mock_app.send_task.call_args.kwargs["headers"] is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 85c36ffaa807e63b1a9bd34fd09fb9ac440015c5 Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 11 Jun 2026 16:07:39 +0530 Subject: [PATCH 02/65] UN-3534 [DOCS] Standardize on "PG Queue" naming; drop PGMQ branding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We don't use the pgmq project (github.com/pgmq/pgmq) — no extension, no Python package, no copied SQL. The queue is a bespoke SKIP LOCKED schema (see the extension-free decision on UN-3533). Rename the 5 prose spots that called our substrate "PGMQ" to "PG Queue" so the code no longer reads as if it depends on that project. PGMQ stays only as prior-art reference in the decision record, not as the name of our substrate. Docs-only; no code or behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- workers/queue_backend/pg_queue/__init__.py | 11 ++++++----- workers/queue_backend/routing.py | 2 +- workers/sample.env | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/workers/queue_backend/pg_queue/__init__.py b/workers/queue_backend/pg_queue/__init__.py index a85622cfe8..b1d520f4dc 100644 --- a/workers/queue_backend/pg_queue/__init__.py +++ b/workers/queue_backend/pg_queue/__init__.py @@ -1,9 +1,10 @@ -"""PG Queue (PGMQ) transport substrate — scaffold. +"""PG Queue transport substrate — scaffold. -Reserved home for the PostgreSQL-backed queue substrate (PGMQ + -``SKIP LOCKED``) that will run alongside Celery during the Strangler-Fig -migration. PGMQ is a *core* worker transport — the intended primary -backend — so it lives inside the queue-backend seam next to its sibling +Reserved home for the PostgreSQL-backed queue substrate (a bespoke +``SKIP LOCKED`` queue, no extension) that will run alongside Celery during +the Strangler-Fig migration. PG Queue is a *core* worker transport — the +intended primary backend — so it lives inside the queue-backend seam next +to its sibling substrates (``dispatch``, ``routing``, ``barrier``, ``redis_barrier``), **not** under ``workers/plugins/``, whose plugin *implementation subdirectories* are the git-ignored overlay copied in at build time (the diff --git a/workers/queue_backend/routing.py b/workers/queue_backend/routing.py index abc43e8029..82cce0d67e 100644 --- a/workers/queue_backend/routing.py +++ b/workers/queue_backend/routing.py @@ -1,7 +1,7 @@ """Queue-transport routing gate (Strangler-Fig). Decides whether a given dispatch should ride the **Celery** transport -(today's only real path) or the future **PG Queue** (PGMQ) transport, +(today's only real path) or the future **PG Queue** transport, based on a per-task-type opt-in allow-list read from env: - ``WORKER_PG_QUEUE_ENABLED_TASKS`` — comma-separated task names routed diff --git a/workers/sample.env b/workers/sample.env index 6e9aaf2d2f..9d6f6a61fe 100644 --- a/workers/sample.env +++ b/workers/sample.env @@ -135,7 +135,7 @@ WORKER_BARRIER_KEY_TTL_SECONDS=21600 # PG Queue Routing (Strangler-Fig) # ============================================================================= # Selects the queue *transport* per task — Celery (default) or PG Queue -# (PGMQ). Separate axis from the barrier above: the barrier is how a +# (PG Queue). Separate axis from the barrier above: the barrier is how a # batch's fan-in fires the callback; this is how messages travel. # # A comma-separated allow-list drives a gradual, per-task-type migration From 2b6714f7b11863db1d9719d0d953476ae30777c5 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:35:25 +0530 Subject: [PATCH 03/65] =?UTF-8?q?UN-3537=20[FEAT]=20PG=20Queue=20Phase=209?= =?UTF-8?q?a=20=E2=80=94=20extension-free=20queue=20schema=20+=20SKIP=20LO?= =?UTF-8?q?CKED=20dequeue=20(#2036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3537 [FEAT] PG Queue Phase 9a — extension-free queue schema + SKIP LOCKED dequeue The storage + dequeue primitive the routing seam will route to. Inert: nothing in dispatch() calls it yet (the PG branch still routes to Celery), so zero behaviour change even on the integration branch. Backend (schema only — SHARED_APPS, cross-org infra, shared schema): - new pg_queue app; 0001 is 100% makemigrations-generated (pg_queue_message table + dequeue index). No CREATE EXTENSION, no DB-side function — plain Django. managed=True model doubles as a typed read handle. Workers (the client; first direct-DB worker capability): - queue_backend/pg_queue: send / read / delete + QueueMessage. read() runs one atomic UPDATE ... FOR UPDATE SKIP LOCKED ... RETURNING (visibility- timeout pattern): claim+commit, process outside the txn, delete on success; a crash lets vt expire and the row redelivers (at-least-once, no double-delivery, VACUUM-safe, PgBouncer txn-pooling compatible). - connection.py reuses the backend DB_* env -> PgBouncer in cloud, direct in OSS (UN-3533 decision). - psycopg2-binary==2.9.9 (matches backend), promoted to a direct dep. Tests: 4 unit (mocked SQL shape) + 4 integration (real Postgres) proving roundtrip, vt-hiding, vt-expiry redelivery, and no-double-delivery. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3537 [FIX] org_id empty-string default, not null=True (Django S6553) A string-based field shouldn't have two "no data" values (NULL and ""). Use default="" for "no org" (leaf tasks) instead of null=True; regenerate the generated 0001 accordingly. The client coerces None -> "" since the column is now non-null. Addresses SonarCloud S6553. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3537 [FIX] Address Phase 9a review feedback Robustness + docstring-accuracy fixes on the PG-queue primitive: - client: roll back on error and recover a dead connection (drop the cached conn on OperationalError/InterfaceError -> next call reconnects); add close() + context manager. One connection blip no longer wedges the 9c consumer. - client.read(): raise on non-positive vt_seconds/qty (vt<=0 is a silent double-delivery window). - client.delete(): WARN when no row was removed (the at-least-once re-delivery signal was previously swallowed). - QueueMessage: slots=True + doc that the payload is decoded JSONB (the dict stays mutable; frozen freezes the binding only). - connection: parameterise create_pg_connection(env_prefix); wrap connect with a self-identifying error log (non-secret host/port/dbname/schema); drop the stale pg_queue_read reference; soften the DB_HOST default claim. - models: fix stale docstrings describing a removed 0002 / DB-level defaults / pg_queue_read function. - tests: narrow integration skip to OperationalError (don't mask ImportError/bugs/permission errors as a green skip); rollback before the teardown DELETE; integration conn delegates to create_pg_connection(env_prefix="TEST_DB_"); add unit coverage for create_pg_connection, read() validation, and error-rollback. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3537 [FIX] Address Phase 9a review round 2 + SonarCloud S2068 - perf: ORDER BY (vt, msg_id) so the (queue_name, vt, msg_id) index drives an indexed top-N instead of sorting the whole visible backlog on each read() — a real regression on a deep queue. - recovery: a failed rollback now proves the connection is dead, so a poisoned connection is recycled regardless of which psycopg2 error subclass was raised (not only Operational/Interface); also checks conn.closed. Closes the "one blip wedges the consumer" gap more fully. - docs: clarify the contract — at-least-once means a message CAN be processed more than once after vt-expiry; SKIP LOCKED only prevents concurrent double-claim. "(no double-delivery)" -> "(no concurrent double-claim)". - tests: cover the recovery/ownership branches (owned conn recycled on OperationalError and on failed rollback; injected conn never closed; close() owned-vs-injected). - security (SonarCloud S2068): the create_pg_connection test mapped a literal "PASSWORD": "p" -> use a runtime uuid token so it isn't flagged as a hard-coded credential. Resolves the Security Rating C gate failure. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/backend/settings/base.py | 2 + backend/pg_queue/__init__.py | 0 backend/pg_queue/apps.py | 7 + backend/pg_queue/migrations/0001_initial.py | 34 ++ backend/pg_queue/migrations/__init__.py | 0 backend/pg_queue/models.py | 37 +++ workers/pyproject.toml | 3 + workers/queue_backend/pg_queue/__init__.py | 21 +- workers/queue_backend/pg_queue/client.py | 215 +++++++++++++ workers/queue_backend/pg_queue/connection.py | 67 ++++ workers/tests/test_pg_queue_client.py | 321 +++++++++++++++++++ workers/uv.lock | 2 + 12 files changed, 704 insertions(+), 5 deletions(-) create mode 100644 backend/pg_queue/__init__.py create mode 100644 backend/pg_queue/apps.py create mode 100644 backend/pg_queue/migrations/0001_initial.py create mode 100644 backend/pg_queue/migrations/__init__.py create mode 100644 backend/pg_queue/models.py create mode 100644 workers/queue_backend/pg_queue/client.py create mode 100644 workers/queue_backend/pg_queue/connection.py create mode 100644 workers/tests/test_pg_queue_client.py diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index 479cb5476b..787a7603e6 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -306,6 +306,8 @@ def filter(self, record): # For the organization model "account_v2", "account_usage", + # PG Queue — extension-free bespoke queue (cross-org infra, shared schema) + "pg_queue", # Django apps should go below this line "django.contrib.admin", "django.contrib.auth", diff --git a/backend/pg_queue/__init__.py b/backend/pg_queue/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pg_queue/apps.py b/backend/pg_queue/apps.py new file mode 100644 index 0000000000..93333d2fe7 --- /dev/null +++ b/backend/pg_queue/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PgQueueConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pg_queue" + verbose_name = "PG Queue" diff --git a/backend/pg_queue/migrations/0001_initial.py b/backend/pg_queue/migrations/0001_initial.py new file mode 100644 index 0000000000..770f06aed0 --- /dev/null +++ b/backend/pg_queue/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.1 on 2026-06-11 14:42 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="PgQueueMessage", + fields=[ + ("msg_id", models.BigAutoField(primary_key=True, serialize=False)), + ("queue_name", models.TextField()), + ("message", models.JSONField()), + ("org_id", models.TextField(blank=True, default="")), + ("enqueued_at", models.DateTimeField(default=django.utils.timezone.now)), + ("vt", models.DateTimeField(default=django.utils.timezone.now)), + ("read_ct", models.IntegerField(default=0)), + ], + options={ + "db_table": "pg_queue_message", + "indexes": [ + models.Index( + fields=["queue_name", "vt", "msg_id"], + name="pg_queue_message_dequeue_idx", + ) + ], + }, + ), + ] diff --git a/backend/pg_queue/migrations/__init__.py b/backend/pg_queue/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py new file mode 100644 index 0000000000..49c63f4b9a --- /dev/null +++ b/backend/pg_queue/models.py @@ -0,0 +1,37 @@ +from django.db import models +from django.utils import timezone + + +class PgQueueMessage(models.Model): + """Bespoke extension-free queue table (``SKIP LOCKED`` + visibility timeout). + + The table and its dequeue index are fully Django-managed (generated by + ``makemigrations``, migration 0001) — no hand-written migration, no DB + function, no ``CREATE EXTENSION`` (UN-3533 extension-free decision). The + ``SKIP LOCKED`` dequeue itself is a single statement in the workers' + ``pg_queue`` client, not a DB-side function. This model doubles as a + typed read handle for the backend producer side. + """ + + msg_id = models.BigAutoField(primary_key=True) + queue_name = models.TextField() + message = models.JSONField() + # "" = no org (leaf tasks); the fair-admission query (a later phase) + # uses it for the coupled pipeline. Empty string rather than NULL — + # a string field shouldn't have two "no data" values (Django S6553). + org_id = models.TextField(blank=True, default="") + # Python-level defaults let ORM ``.create()`` work without these; the + # raw-SQL client supplies them explicitly (``now()`` / ``0``), so no + # DB-level default is required. + enqueued_at = models.DateTimeField(default=timezone.now) + vt = models.DateTimeField(default=timezone.now) # visibility timeout + read_ct = models.IntegerField(default=0) + + class Meta: + db_table = "pg_queue_message" + indexes = [ + models.Index( + fields=["queue_name", "vt", "msg_id"], + name="pg_queue_message_dequeue_idx", + ) + ] diff --git a/workers/pyproject.toml b/workers/pyproject.toml index 6344f8798d..e3368a4eea 100644 --- a/workers/pyproject.toml +++ b/workers/pyproject.toml @@ -34,6 +34,9 @@ dependencies = [ "unstract-filesystem", # 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 + # (reuses the backend's DB_* connection: PgBouncer in cloud, direct in OSS) + "psycopg2-binary==2.9.9", # matches backend; first direct-DB worker capability # Streaming utilities for bulk downloads (pluggable worker) "zipstream-ng>=1.7.0", # Streaming zip creation for memory-efficient bulk downloads # Note: Using dataclasses instead of pydantic for lightweight typing diff --git a/workers/queue_backend/pg_queue/__init__.py b/workers/queue_backend/pg_queue/__init__.py index b1d520f4dc..4b0aee5782 100644 --- a/workers/queue_backend/pg_queue/__init__.py +++ b/workers/queue_backend/pg_queue/__init__.py @@ -14,12 +14,23 @@ real implementation will likely span several modules (config, consumer poll loop, orchestrator) — exact layout TBD. -Empty by design in this phase. Routing decisions are made by -:func:`queue_backend.routing.select_backend`; until a consumer exists -here, PG-selected dispatches still ride Celery (see -``queue_backend.dispatch``). +Phase 9a adds the storage + dequeue primitive (:class:`PgQueueClient` +over the ``pg_queue_message`` table; the ``SKIP LOCKED`` dequeue lives in +the client). It is still **inert**: routing decisions are made by +:func:`queue_backend.routing.select_backend`, and until the enqueue +wiring (9b) and consumer (9c) land, PG-selected dispatches still ride +Celery (see ``queue_backend.dispatch``). Design reference: the PG Queue implementation guide in the labs repo (``workflow-execution-architecture``). Branch and section pointers move, -so they're tracked on UN-3534 / the PR rather than baked in here. +so they're tracked on the ticket / PR rather than baked in here. """ + +from .client import PgQueueClient, QueueMessage +from .connection import create_pg_connection + +__all__ = [ + "PgQueueClient", + "QueueMessage", + "create_pg_connection", +] diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py new file mode 100644 index 0000000000..fb61147b8c --- /dev/null +++ b/workers/queue_backend/pg_queue/client.py @@ -0,0 +1,215 @@ +"""Thin client over the bespoke PG queue (extension-free, ``SKIP LOCKED``). + +**Inert in this phase** — nothing in ``dispatch()`` calls this yet (the +routing gate's PG branch still routes to Celery). This is the storage + +dequeue primitive that the enqueue wiring (9b) and the consumer poll +loop (9c) build on. + +Dequeue uses the visibility-timeout pattern: :meth:`PgQueueClient.read` +runs a single atomic ``UPDATE … WHERE msg_id IN (SELECT … FOR UPDATE +SKIP LOCKED …) RETURNING …`` (committed immediately), the caller +processes the message *outside* the transaction, then +:meth:`PgQueueClient.delete` acks on success. A crash before delete +leaves the row to reappear once its ``vt`` expires — **at-least-once** +delivery: SKIP LOCKED stops two *concurrent* readers from claiming the +same visible row, but a message can still be delivered more than once if +a reader crashes before ``delete()`` and the row's ``vt`` expires, so the +consumer must be idempotent. The whole queue contract lives here, in one place; +the schema (``pg_queue_message`` table + dequeue index) is a plain +Django migration with no DB-side function. + +The cached connection is kept usable across calls: every operation rolls +back on error, and a connection that goes bad (dropped socket / PgBouncer +recycle) is discarded so the next call reconnects — so one blip can't +permanently wedge the 9c consumer. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +from collections.abc import Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Self + +import psycopg2 + +from .connection import create_pg_connection + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + +logger = logging.getLogger(__name__) + +# Atomic claim. Takes up to %(qty)s ready messages no other transaction +# holds, makes them invisible for %(vt)s seconds, returns them. SKIP LOCKED +# => concurrent readers never claim the same visible row (no concurrent +# double-claim). The caller commits immediately, processes OUTSIDE the txn, +# DELETEs on success; a crash leaves the row to reappear when vt expires +# (at-least-once — a message CAN be processed more than once). No lock held +# during processing -> VACUUM-safe and PgBouncer txn-pooling compatible. +# +# ORDER BY (vt, msg_id) — not just msg_id — so the (queue_name, vt, msg_id) +# index drives an indexed top-N: for a fixed queue the index is ordered by +# (vt, msg_id), so the vt<=now() range scan emits rows already sorted and PG +# applies LIMIT without sorting the whole visible backlog. Still effectively +# FIFO (vt is set to now() at enqueue; msg_id is the deterministic tiebreak). +_DEQUEUE_SQL = """ +UPDATE pg_queue_message + SET vt = now() + make_interval(secs => %s), + read_ct = read_ct + 1 + WHERE msg_id IN ( + SELECT msg_id + FROM pg_queue_message + WHERE queue_name = %s + AND vt <= now() + ORDER BY vt, msg_id + FOR UPDATE SKIP LOCKED + LIMIT %s + ) +RETURNING msg_id, message +""" + + +@dataclass(frozen=True, slots=True) +class QueueMessage: + """A claimed queue message. + + ``message`` is the already-decoded JSONB payload (psycopg2 parses + ``jsonb`` to a Python ``dict``). ``frozen`` freezes the binding only — + the ``dict`` itself is mutable, so treat the payload as read-only by + convention. + """ + + msg_id: int + message: dict[str, Any] + + +class PgQueueClient: + """``send`` / ``read`` / ``delete`` over ``pg_queue_message``. + + A connection may be injected (tests); otherwise one is created lazily + from the backend ``DB_*`` env on first use and owned by this client + (closed by :meth:`close`, recovered automatically after a connection + error). Usable as a context manager. + """ + + def __init__(self, conn: PgConnection | None = None) -> None: + self._conn = conn + # Injected connections belong to the caller — never close/recycle them. + self._owns_conn = conn is None + + @property + def conn(self) -> PgConnection: + if self._conn is None: + self._conn = create_pg_connection() + return self._conn + + @contextlib.contextmanager + def _cursor(self) -> Iterator[Any]: + """Yield a cursor; commit on success, roll back + recover on error. + + Keeps the cached connection usable: a failed statement leaves the + connection in an aborted transaction, so we always roll back; a + dead connection can't be reused, so (when we own it) we drop the + cached handle and the next call reconnects. + """ + conn = self.conn + try: + with conn.cursor() as cur: + yield cur + conn.commit() + except Exception as exc: + # A failed rollback proves the connection is unusable, regardless + # of which psycopg2 error subclass was raised (a server-side + # termination mid-statement can surface as a bare DatabaseError). + conn_dead = isinstance( + exc, (psycopg2.OperationalError, psycopg2.InterfaceError) + ) + try: + conn.rollback() + except Exception: + conn_dead = True + # Discard an unusable connection so the next call reconnects — + # only when we own it (an injected connection is the caller's). + if self._owns_conn and (conn_dead or conn.closed): + with contextlib.suppress(Exception): + conn.close() + self._conn = None + raise + + def send( + self, queue_name: str, message: dict[str, Any], *, org_id: str | None = None + ) -> int: + """Enqueue a message; returns its ``msg_id``. + + Immediately visible — ``vt`` is set to ``now()`` (DB clock). The + timestamp/counter columns are supplied here rather than via DB + defaults so the schema stays a plain Django migration. + """ + with self._cursor() as cur: + cur.execute( + "INSERT INTO pg_queue_message " + "(queue_name, message, org_id, enqueued_at, vt, read_ct) " + "VALUES (%s, %s::jsonb, %s, now(), now(), 0) RETURNING msg_id", + # "" rather than NULL for "no org" — the column is non-null + # (string fields shouldn't have two empty values; Django S6553). + (queue_name, json.dumps(message), org_id if org_id is not None else ""), + ) + msg_id = cur.fetchone()[0] + return int(msg_id) + + def read( + self, queue_name: str, *, vt_seconds: int = 30, qty: int = 1 + ) -> list[QueueMessage]: + """Atomically claim up to ``qty`` ready messages, hiding them for ``vt_seconds``. + + Commits immediately so the row lock is released and the ``vt`` + bump persists — claimed messages are then invisible to other + readers until ``vt`` expires or :meth:`delete` removes them. + + Raises ``ValueError`` for non-positive ``vt_seconds`` (which would + make a claimed message immediately re-visible — a double-delivery + window) or ``qty`` (a pointless / erroring ``LIMIT``). + """ + if vt_seconds <= 0: + raise ValueError(f"vt_seconds must be positive, got {vt_seconds}") + if qty <= 0: + raise ValueError(f"qty must be positive, got {qty}") + with self._cursor() as cur: + cur.execute(_DEQUEUE_SQL, (vt_seconds, queue_name, qty)) + rows = cur.fetchall() + return [QueueMessage(msg_id=int(r[0]), message=r[1]) for r in rows] + + def delete(self, msg_id: int) -> bool: + """Ack a processed message. Returns ``True`` if a row was removed. + + ``False`` means the row was already gone — typically its visibility + timeout expired during processing and another worker (re)claimed it, + i.e. the work may be processed twice. Logged at WARNING so this + at-least-once condition is visible rather than silently swallowed. + """ + with self._cursor() as cur: + cur.execute("DELETE FROM pg_queue_message WHERE msg_id = %s", (msg_id,)) + deleted = cur.rowcount + if deleted == 0: + logger.warning( + "PG-queue: delete(msg_id=%s) removed no row — message was already " + "gone (vt likely expired during processing; possible re-delivery).", + msg_id, + ) + return deleted == 1 + + def close(self) -> None: + """Close the connection if this client owns it (no-op if injected).""" + if self._owns_conn and self._conn is not None: + with contextlib.suppress(Exception): + self._conn.close() + self._conn = None + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_exc: object) -> None: + self.close() diff --git a/workers/queue_backend/pg_queue/connection.py b/workers/queue_backend/pg_queue/connection.py new file mode 100644 index 0000000000..53c1f414f9 --- /dev/null +++ b/workers/queue_backend/pg_queue/connection.py @@ -0,0 +1,67 @@ +"""Direct Postgres connection for the PG-queue client. + +The PG queue is the first worker component to open a *direct* Postgres +connection — workers otherwise reach state via the Internal API, with +Redis the only other direct store. It reuses the **backend's** ``DB_*`` +env so the target follows the deployment: in cloud those vars point at +PgBouncer (connection pooling); in OSS / on-prem they point straight at +Postgres (UN-3533 decision). + +``search_path`` is set to ``DB_SCHEMA`` so the bespoke +``pg_queue_message`` table resolves in the same schema the backend +manages. +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +import psycopg2 + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + +logger = logging.getLogger(__name__) + + +def create_pg_connection(env_prefix: str = "DB_") -> PgConnection: + """Open a direct Postgres connection from ``{env_prefix}*`` env. + + The default ``DB_`` prefix reads the same variable *names* the backend + uses (``DB_HOST`` / ``DB_PORT`` / ``DB_NAME`` / ``DB_USER`` / + ``DB_PASSWORD`` / ``DB_SCHEMA``), so the queue connects to the + backend's database and schema. Tests pass ``env_prefix="TEST_DB_"`` to + target a dev DB. The fallbacks below are dev-compose values (the host + is the compose service name); real deployments always set the env + explicitly, so the fallback host intentionally differs from the + backend's own ``base.py`` default. + """ + host = os.getenv(f"{env_prefix}HOST", "unstract-db") + port = os.getenv(f"{env_prefix}PORT", "5432") + dbname = os.getenv(f"{env_prefix}NAME", "unstract_db") + user = os.getenv(f"{env_prefix}USER", "unstract_dev") + schema = os.getenv(f"{env_prefix}SCHEMA", "unstract") + try: + return psycopg2.connect( + host=host, + port=port, + dbname=dbname, + user=user, + password=os.getenv(f"{env_prefix}PASSWORD", "unstract_pass"), + options=f"-c search_path={schema}", + ) + except psycopg2.OperationalError: + # First direct-DB worker component — make the failure self-identifying + # so a misconfigured DB_* var is obvious. Secrets (password) omitted. + logger.error( + "PG-queue: failed to connect to Postgres " + "(host=%s port=%s dbname=%s schema=%s, env_prefix=%r)", + host, + port, + dbname, + schema, + env_prefix, + ) + raise diff --git a/workers/tests/test_pg_queue_client.py b/workers/tests/test_pg_queue_client.py new file mode 100644 index 0000000000..fbc4bcd579 --- /dev/null +++ b/workers/tests/test_pg_queue_client.py @@ -0,0 +1,321 @@ +"""Tests for the PG-queue client (``queue_backend.pg_queue``). + +Two layers: + +1. **Unit** (mocked connection) — ``send`` / ``read`` / ``delete`` issue + the right SQL + params and commit. Always runs, no DB needed. + +2. **Integration** (real Postgres) — exercises the actual ``SKIP LOCKED`` + + visibility-timeout semantics against a live DB. Skips gracefully if + Postgres isn't reachable or the ``pg_queue`` migration hasn't been + applied. +""" + +from __future__ import annotations + +import os +import time +import uuid +from unittest.mock import MagicMock + +import psycopg2 +import pytest +from queue_backend.pg_queue import PgQueueClient, QueueMessage +from queue_backend.pg_queue.connection import create_pg_connection + +# --- Unit: SQL shape against a mocked connection --- + + +class _CursorCtx: + """Mimic a psycopg2 cursor used as a context manager.""" + + def __init__(self, cursor): + self._cursor = cursor + + def __enter__(self): + return self._cursor + + def __exit__(self, *_): + return False + + +def _mock_conn(*, fetchone=None, fetchall=None, rowcount=0): + cur = MagicMock() + cur.fetchone.return_value = fetchone + cur.fetchall.return_value = fetchall or [] + cur.rowcount = rowcount + conn = MagicMock() + conn.cursor.return_value = _CursorCtx(cur) + return conn, cur + + +class TestPgQueueClientUnit: + def test_send_inserts_and_returns_msg_id(self): + conn, cur = _mock_conn(fetchone=(42,)) + msg_id = PgQueueClient(conn=conn).send("q1", {"a": 1}, org_id="org-9") + assert msg_id == 42 + sql, params = cur.execute.call_args.args + assert "INSERT INTO pg_queue_message" in sql + assert params[0] == "q1" + assert params[2] == "org-9" + assert '"a": 1' in params[1] # message JSON-serialised + conn.commit.assert_called_once() + + def test_send_coerces_missing_org_to_empty_string(self): + # org_id column is non-null (Django S6553) — None must become "". + conn, cur = _mock_conn(fetchone=(1,)) + PgQueueClient(conn=conn).send("q1", {"a": 1}) + _, params = cur.execute.call_args.args + assert params[2] == "" + + def test_read_runs_skip_locked_dequeue(self): + conn, cur = _mock_conn(fetchall=[(7, {"k": "v"})]) + msgs = PgQueueClient(conn=conn).read("q1", vt_seconds=15, qty=3) + sql, params = cur.execute.call_args.args + assert "FOR UPDATE SKIP LOCKED" in sql + assert "UPDATE pg_queue_message" in sql + # Param order follows the %s positions: vt_seconds, queue_name, qty. + assert params == (15, "q1", 3) + assert msgs == [QueueMessage(msg_id=7, message={"k": "v"})] + conn.commit.assert_called_once() + + def test_delete_returns_true_when_row_removed(self): + conn, cur = _mock_conn(rowcount=1) + assert PgQueueClient(conn=conn).delete(7) is True + sql, params = cur.execute.call_args.args + assert "DELETE FROM pg_queue_message" in sql + assert params == (7,) + conn.commit.assert_called_once() + + def test_delete_returns_false_when_no_row(self): + conn, _ = _mock_conn(rowcount=0) + assert PgQueueClient(conn=conn).delete(999) is False + + def test_read_rejects_non_positive_vt(self): + conn, _ = _mock_conn() + with pytest.raises(ValueError, match="vt_seconds"): + PgQueueClient(conn=conn).read("q1", vt_seconds=0) + + def test_read_rejects_non_positive_qty(self): + conn, _ = _mock_conn() + with pytest.raises(ValueError, match="qty"): + PgQueueClient(conn=conn).read("q1", qty=0) + + def test_error_rolls_back_and_reraises(self): + conn, cur = _mock_conn() + cur.execute.side_effect = RuntimeError("boom") + with pytest.raises(RuntimeError): + PgQueueClient(conn=conn).send("q1", {"a": 1}) + conn.rollback.assert_called_once() + conn.commit.assert_not_called() + + +class TestConnectionLifecycle: + """Recovery + ownership branches of ``_cursor()`` / ``close()``.""" + + @staticmethod + def _owned_client(monkeypatch, *, execute_raises=None): + cur = MagicMock() + if execute_raises is not None: + cur.execute.side_effect = execute_raises + conn = MagicMock() + conn.closed = 0 + conn.cursor.return_value = _CursorCtx(cur) + factory = MagicMock(return_value=conn) + monkeypatch.setattr( + "queue_backend.pg_queue.client.create_pg_connection", factory + ) + return PgQueueClient(), conn, factory + + def test_owned_conn_recovered_on_operational_error(self, monkeypatch): + client, conn, factory = self._owned_client( + monkeypatch, execute_raises=psycopg2.OperationalError("dead") + ) + with pytest.raises(psycopg2.OperationalError): + client.send("q", {"a": 1}) + conn.rollback.assert_called_once() + conn.close.assert_called_once() + assert client._conn is None # cached handle dropped + _ = client.conn # next op reconnects + assert factory.call_count == 2 + + def test_owned_conn_recovered_when_rollback_fails(self, monkeypatch): + # A non-Operational error whose rollback fails → still treated as dead. + client, conn, _ = self._owned_client( + monkeypatch, execute_raises=RuntimeError("boom") + ) + conn.rollback.side_effect = psycopg2.DatabaseError("socket gone") + with pytest.raises(RuntimeError): + client.send("q", {"a": 1}) + conn.close.assert_called_once() + assert client._conn is None + + def test_injected_conn_never_closed_on_error(self): + cur = MagicMock() + cur.execute.side_effect = psycopg2.OperationalError("dead") + conn = MagicMock() + conn.closed = 0 + conn.cursor.return_value = _CursorCtx(cur) + client = PgQueueClient(conn=conn) + with pytest.raises(psycopg2.OperationalError): + client.send("q", {"a": 1}) + conn.rollback.assert_called_once() + conn.close.assert_not_called() + assert client._conn is conn # caller's connection untouched + + def test_close_closes_owned_not_injected(self, monkeypatch): + client, conn, _ = self._owned_client(monkeypatch) + _ = client.conn # lazily create + client.close() + conn.close.assert_called_once() + assert client._conn is None + + injected = MagicMock() + PgQueueClient(conn=injected).close() + injected.close.assert_not_called() + + +class TestCreatePgConnection: + """Unit coverage for the connection factory (no real DB).""" + + def test_reads_env_prefix_and_sets_search_path(self, monkeypatch): + captured = {} + monkeypatch.setattr( + "queue_backend.pg_queue.connection.psycopg2.connect", + lambda **kw: captured.update(kw) or object(), + ) + # Password is a runtime token (not a hard-coded literal) so it isn't + # mistaken for a credential, while still proving the env is read. + secret = uuid.uuid4().hex + for k, v in { + "HOST": "h", + "PORT": "6432", + "NAME": "n", + "USER": "u", + "PASSWORD": secret, + "SCHEMA": "s", + }.items(): + monkeypatch.setenv(f"DB_{k}", v) + create_pg_connection() + assert captured["host"] == "h" + assert captured["port"] == "6432" + assert captured["dbname"] == "n" + assert captured["user"] == "u" + assert captured["password"] == secret + assert captured["options"] == "-c search_path=s" + + def test_env_prefix_override(self, monkeypatch): + captured = {} + monkeypatch.setattr( + "queue_backend.pg_queue.connection.psycopg2.connect", + lambda **kw: captured.update(kw) or object(), + ) + monkeypatch.setenv("TEST_DB_HOST", "test-host") + create_pg_connection(env_prefix="TEST_DB_") + assert captured["host"] == "test-host" + + def test_connect_failure_is_logged_and_reraised(self, monkeypatch, caplog): + import logging + + def boom(**_): + raise psycopg2.OperationalError("nope") + + monkeypatch.setattr( + "queue_backend.pg_queue.connection.psycopg2.connect", boom + ) + with ( + caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.connection"), + pytest.raises(psycopg2.OperationalError), + ): + create_pg_connection() + assert "failed to connect" in caplog.text + + +# --- Integration: real Postgres --- + + +def _integration_conn(): + # Reuse create_pg_connection (single source of connection logic) via the + # dedicated TEST_DB_* prefix — NOT the generic DB_*, which the suite's + # conftest sets to DB_USER=test for unit isolation (wrong real-DB creds). + # Default the host to the dev-compose published port on localhost. + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + return create_pg_connection(env_prefix="TEST_DB_") + + +@pytest.fixture +def pg_conn(): + try: + conn = _integration_conn() + except psycopg2.OperationalError as exc: + # Only an unreachable/unauthenticated DB skips — ImportError, bugs, + # and schema/permission errors surface as real failures. + pytest.skip(f"Postgres not reachable: {exc}") + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('pg_queue_message')") + (table,) = cur.fetchone() + if table is None: + conn.close() + pytest.skip("pg_queue migration not applied (run backend migrate)") + yield conn + conn.rollback() + conn.close() + + +@pytest.fixture +def queue_name(pg_conn): + # Unique per test for isolation; clean up rows afterwards. + name = f"test_q_{os.getpid()}_{int(time.time() * 1000)}" + yield name + # A failed test body can leave the connection in an aborted transaction; + # roll back first so this cleanup doesn't raise InFailedSqlTransaction. + pg_conn.rollback() + with pg_conn.cursor() as cur: + cur.execute("DELETE FROM pg_queue_message WHERE queue_name = %s", (name,)) + pg_conn.commit() + + +class TestPgQueueClientIntegration: + def test_send_read_delete_roundtrip(self, pg_conn, queue_name): + client = PgQueueClient(conn=pg_conn) + msg_id = client.send(queue_name, {"hello": "world"}) + msgs = client.read(queue_name, vt_seconds=30, qty=10) + assert [m.msg_id for m in msgs] == [msg_id] + assert msgs[0].message == {"hello": "world"} + assert client.delete(msg_id) is True + assert client.read(queue_name, vt_seconds=30, qty=10) == [] # gone + + def test_read_hides_message_for_vt(self, pg_conn, queue_name): + client = PgQueueClient(conn=pg_conn) + client.send(queue_name, {"n": 1}) + assert len(client.read(queue_name, vt_seconds=30, qty=10)) == 1 + # Second read within vt sees nothing. + assert client.read(queue_name, vt_seconds=30, qty=10) == [] + + def test_vt_expiry_redelivers(self, pg_conn, queue_name): + client = PgQueueClient(conn=pg_conn) + client.send(queue_name, {"n": 1}) + claimed = client.read(queue_name, vt_seconds=1, qty=10) + assert len(claimed) == 1 + time.sleep(1.3) # let vt expire + again = client.read(queue_name, vt_seconds=30, qty=10) + assert [m.msg_id for m in again] == [c.msg_id for c in claimed] + + def test_no_double_delivery_across_readers(self, pg_conn, queue_name): + """Two readers never claim the same message (SKIP LOCKED + vt).""" + client_a = PgQueueClient(conn=pg_conn) + for i in range(5): + client_a.send(queue_name, {"i": i}) + conn_b = _integration_conn() + try: + client_b = PgQueueClient(conn=conn_b) + ids_a = {m.msg_id for m in client_a.read(queue_name, vt_seconds=30, qty=3)} + ids_b = {m.msg_id for m in client_b.read(queue_name, vt_seconds=30, qty=3)} + assert ids_a.isdisjoint(ids_b) # no double-delivery + assert len(ids_a) + len(ids_b) == 5 # all delivered exactly once + finally: + conn_b.close() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/workers/uv.lock b/workers/uv.lock index cfd1e7c96d..6f625ec13b 100644 --- a/workers/uv.lock +++ b/workers/uv.lock @@ -4903,6 +4903,7 @@ dependencies = [ { name = "httpx" }, { name = "prometheus-client" }, { name = "psutil" }, + { name = "psycopg2-binary" }, { name = "python-dotenv" }, { name = "python-socketio" }, { name = "redis" }, @@ -4951,6 +4952,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "prometheus-client", specifier = ">=0.17.0,<1.0.0" }, { name = "psutil", specifier = ">=5.9.0,<6.0.0" }, + { name = "psycopg2-binary", specifier = "==2.9.9" }, { name = "python-dotenv", specifier = ">=1.2.2,<2.0.0" }, { name = "python-socketio", specifier = ">=5.9.0" }, { name = "redis", specifier = ">=4.5.0,<6.0.0" }, From 808767717387f1a07af22e2e9287fbeffcddebd2 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:35:42 +0530 Subject: [PATCH 04/65] =?UTF-8?q?UN-3538=20[FEAT]=20PG=20Queue=20Phase=209?= =?UTF-8?q?b=20=E2=80=94=20enqueue=20PG-routed=20tasks=20to=20Postgres=20(?= =?UTF-8?q?#2043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3538 [FEAT] PG Queue Phase 9b — enqueue PG-routed tasks to Postgres When a task is opted into WORKER_PG_QUEUE_ENABLED_TASKS, dispatch() now serialises it as a TaskPayload and enqueues to pg_queue_message instead of Celery, returning a PgDispatchHandle (.id = msg_id). A process-singleton PgQueueClient is reused across dispatches. The Celery path is unchanged and the default-empty flag routes everything to Celery — zero behaviour change. - queue_backend/pg_queue/task_payload.py: TaskPayload TypedDict + to_payload() — the producer<->consumer wire contract. This is the *contents* of the pg_queue_message.message JSONB column, distinct from the backend's PgQueueMessage *row* model (envelope vs payload — they nest, not duplicate). - dispatch(): PG branch enqueues + returns; cutover log (INFO, once per task); .warning docstring that an opt-in requires the 9c consumer running, else the task enqueues but never executes. - sample.env: same warning on the flag. - backend pg_queue model: note that `message` holds a TaskPayload. - tests: TestDispatchRouting (PG->enqueue, Celery->send_task), TestCutoverLog, to_payload shape + real-PG integration (dispatch lands a decodable row). Leaf-only (migration-coherence) — pipeline tasks stay on Celery until execution-level routing (9e). INERT by default. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3538 [FIX] Address Phase 9b review feedback - dispatch: per-thread PG client via threading.local — a libpq connection isn't safe for concurrent use across threads; correct under prefork AND a -P threads pool (gevent would need a pool — noted, out of scope). - dispatch: log the routing *decision* BEFORE send() so a first-dispatch failure (DB down / unmigrated) doesn't suppress the one announcement; wrap the enqueue with a logger.exception breadcrumb (a raw psycopg2.Error or a json.dumps TypeError on a non-serialisable arg otherwise propagate with no "PG-routed dispatch" context). No Celery fallback retained. - dispatch: hoist `pg_queue = queue or _DEFAULT_PG_QUEUE` (one source). - fairness: share a FairnessPayload TypedDict; to_dict() -> FairnessPayload; TaskPayload.fairness uses it instead of a loose dict[str, Any]. - pg_queue/__init__ docstring: describe 9b state (no longer "inert, rides Celery"); sample.env: drop a duplicated routes-to-Celery sentence. - tests: no-fallback-on-enqueue-failure (Critical gap) + log-ordering pin; lazy-init + reuse of the per-thread client; cutover-log assertions updated for the new message. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/pg_queue/models.py | 3 + workers/queue_backend/dispatch.py | 134 +++++++++++---- workers/queue_backend/fairness.py | 26 ++- workers/queue_backend/pg_queue/__init__.py | 18 +- .../queue_backend/pg_queue/task_payload.py | 53 ++++++ workers/sample.env | 14 +- workers/tests/test_dispatch_pg.py | 137 ++++++++++++++++ workers/tests/test_routing.py | 155 +++++++++++------- 8 files changed, 427 insertions(+), 113 deletions(-) create mode 100644 workers/queue_backend/pg_queue/task_payload.py create mode 100644 workers/tests/test_dispatch_pg.py diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index 49c63f4b9a..771fd909e6 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -15,6 +15,9 @@ class PgQueueMessage(models.Model): msg_id = models.BigAutoField(primary_key=True) queue_name = models.TextField() + # Opaque JSONB payload. For task dispatches the workers write a task + # payload here (``queue_backend.pg_queue.TaskPayload``: task_name / args + # / kwargs / queue / fairness); the queue itself stays payload-agnostic. message = models.JSONField() # "" = no org (leaf tasks); the fair-admission query (a later phase) # uses it for the coupled pipeline. Empty string rather than NULL — diff --git a/workers/queue_backend/dispatch.py b/workers/queue_backend/dispatch.py index e986ecb6f4..68261293df 100644 --- a/workers/queue_backend/dispatch.py +++ b/workers/queue_backend/dispatch.py @@ -1,29 +1,74 @@ """Transport-agnostic task dispatch. -Thin pass-through to ``celery.current_app.send_task``; the indirection -is the seam — a future per-task router can land here without touching -call sites. +Routes each task to its transport via :func:`select_backend`: + +- **Celery** (default) — a thin pass-through to ``current_app.send_task``. +- **PG Queue** — when a task is opted into ``WORKER_PG_QUEUE_ENABLED_TASKS``, + the task is serialised and enqueued to ``pg_queue_message`` (9b); the PG + consumer (9c) drains and runs it. + +The default (empty allow-list) routes everything to Celery, so dispatch is +unchanged unless an operator explicitly opts a task in. + +.. warning:: + A task opted into the PG queue **requires the PG consumer to be running** + — otherwise the message is durably enqueued but never executed. Only opt + in tasks once the consumer is deployed (and, per the migration-coherence + decision, only *leaf* tasks until execution-level routing exists). """ from __future__ import annotations import logging +import threading from collections.abc import Mapping, Sequence +from dataclasses import dataclass from typing import Any from celery import current_app from .fairness import FairnessKey from .handle import DispatchHandle +from .pg_queue import PgQueueClient, to_payload from .routing import QueueBackend, select_backend logger = logging.getLogger(__name__) -# Task names already logged as PG-routed in this process. Bounds the -# routing log to once per task name (per prefork child) so an opted-in -# high-throughput task doesn't log on every dispatch. +# pg_queue_message.queue_name used when a dispatch carries no Celery queue. +_DEFAULT_PG_QUEUE = "default" + +# Task names already logged as PG-routed in this process — bounds the cutover +# log to once per task name (per prefork child). _pg_routing_logged: set[str] = set() +# Per-thread client, reused across dispatches (it self-recovers a dead +# connection). The worker pool is prefork (each child a single thread), so +# this is effectively one client per child; ``threading.local`` keeps it +# correct under a ``-P threads`` pool too, since a libpq connection is not +# safe for concurrent use across threads. (A gevent pool shares one thread +# across greenlets and would need a connection pool — out of scope while +# prefork is the deployment.) +_pg_local = threading.local() + + +def _get_pg_client() -> PgQueueClient: + client = getattr(_pg_local, "client", None) + if client is None: + client = PgQueueClient() + _pg_local.client = client + return client + + +@dataclass(frozen=True, slots=True) +class PgDispatchHandle: + """:class:`~queue_backend.handle.TaskHandle` for a PG-enqueued task. + + ``id`` is the ``pg_queue_message.msg_id`` (as a string), so call sites + that read ``handle.id`` keep working across the Celery/PG boundary. + """ + + id: str + def dispatch( task_name: str, @@ -33,32 +78,14 @@ def dispatch( queue: str | None = None, fairness: FairnessKey | None = None, ) -> DispatchHandle: - """Enqueue a task by name. - - ``fairness`` is attached as the ``x-fairness-key`` header (not in - kwargs). Pass ``None`` for non-workflow worker tasks. - - The transport is chosen by :func:`select_backend` from the PG-queue - routing table (``WORKER_PG_QUEUE_ENABLED_TASKS``). In this phase the - table is a scaffold: PG-selected tasks are *logged* but still - dispatched via Celery, because no PG consumer exists yet. The - ``QueueBackend.PG`` branch below is the seam where the real PG - enqueue lands in a later phase — keeping the ``send_task`` call - outside it guarantees today's wire is byte-identical regardless of - the routing decision. + """Enqueue a task by name onto its selected transport. + + ``fairness`` is attached as the ``x-fairness-key`` header on the Celery + path / serialised into the message on the PG path. Pass ``None`` for + non-workflow worker tasks. """ - if ( - select_backend(task_name) is QueueBackend.PG - and task_name not in _pg_routing_logged - ): - # INFO (not DEBUG) so the cutover is visible under a default log - # config; log-once per task name keeps the volume bounded. - _pg_routing_logged.add(task_name) - logger.info( - "PG-queue routing selected for task=%r; dispatching via " - "Celery (scaffold — no PG consumer yet)", - task_name, - ) + if select_backend(task_name) is QueueBackend.PG: + return _enqueue_pg(task_name, args, kwargs, queue, fairness) headers = fairness.as_header() if fairness is not None else None return current_app.send_task( @@ -68,3 +95,48 @@ def dispatch( queue=queue, headers=headers, ) + + +def _enqueue_pg( + task_name: str, + args: Sequence[Any] | None, + kwargs: Mapping[str, Any] | None, + queue: str | None, + fairness: FairnessKey | None, +) -> PgDispatchHandle: + """Serialise + enqueue a PG-routed task to ``pg_queue_message``. + + A PG enqueue failure raises (no silent Celery fallback — that would + hide the failure or risk double-dispatch). + """ + pg_queue = queue or _DEFAULT_PG_QUEUE + if task_name not in _pg_routing_logged: + # Log the routing *decision* once per task, BEFORE the send — it's + # true regardless of send outcome, so a first-dispatch failure + # (DB down / unmigrated) must not suppress the one announcement. + # INFO so it survives a default log config; once-per-task bounds it. + _pg_routing_logged.add(task_name) + logger.info( + "PG-queue: routing task=%r to Postgres (queue=%r). Requires the " + "PG consumer to be running, or it will not execute.", + task_name, + pg_queue, + ) + payload = to_payload( + task_name, args=args, kwargs=kwargs, queue=queue, fairness=fairness + ) + try: + msg_id = _get_pg_client().send( + pg_queue, + payload, + org_id=fairness.org_id if fairness is not None else None, + ) + except Exception: + # Re-raise with a breadcrumb (raw psycopg2.Error / a json.dumps + # TypeError on a non-serialisable arg would otherwise propagate with + # no "this was a PG-routed dispatch" context). No Celery fallback. + logger.exception( + "PG-queue: failed to enqueue task=%r to queue=%r", task_name, pg_queue + ) + raise + return PgDispatchHandle(id=str(msg_id)) diff --git a/workers/queue_backend/fairness.py b/workers/queue_backend/fairness.py index 83af4fea1d..160b393e7b 100644 --- a/workers/queue_backend/fairness.py +++ b/workers/queue_backend/fairness.py @@ -9,7 +9,19 @@ from dataclasses import dataclass from enum import StrEnum -from typing import Final +from typing import Final, TypedDict + + +class FairnessPayload(TypedDict): + """Serialised :class:`FairnessKey` (``to_dict()`` / wire shape). + + Shared so producers and the PG ``TaskPayload`` wire contract agree on + the exact sub-shape instead of a loose ``dict[str, Any]``. + """ + + org_id: str | None + workload_type: str + pipeline_priority: int class WorkloadType(StrEnum): @@ -47,12 +59,12 @@ def __post_init__(self) -> None: f"[{MIN_PRIORITY}, {MAX_PRIORITY}]: {self.pipeline_priority}" ) - def to_dict(self) -> dict[str, str | int | None]: - return { - "org_id": self.org_id, - "workload_type": self.workload_type.value, - "pipeline_priority": self.pipeline_priority, - } + def to_dict(self) -> FairnessPayload: + return FairnessPayload( + org_id=self.org_id, + workload_type=self.workload_type.value, + pipeline_priority=self.pipeline_priority, + ) def as_header(self) -> dict[str, dict[str, str | int | None]]: """Celery ``send_task(headers=...)`` payload. diff --git a/workers/queue_backend/pg_queue/__init__.py b/workers/queue_backend/pg_queue/__init__.py index 4b0aee5782..31f51e253d 100644 --- a/workers/queue_backend/pg_queue/__init__.py +++ b/workers/queue_backend/pg_queue/__init__.py @@ -14,12 +14,15 @@ real implementation will likely span several modules (config, consumer poll loop, orchestrator) — exact layout TBD. -Phase 9a adds the storage + dequeue primitive (:class:`PgQueueClient` -over the ``pg_queue_message`` table; the ``SKIP LOCKED`` dequeue lives in -the client). It is still **inert**: routing decisions are made by -:func:`queue_backend.routing.select_backend`, and until the enqueue -wiring (9b) and consumer (9c) land, PG-selected dispatches still ride -Celery (see ``queue_backend.dispatch``). +9a added the storage + dequeue primitive (:class:`PgQueueClient` over the +``pg_queue_message`` table; the ``SKIP LOCKED`` dequeue lives in the +client). 9b wires :func:`queue_backend.dispatch.dispatch` to enqueue +PG-selected tasks here as a :class:`TaskPayload` instead of sending to +Celery. The consumer poll loop that drains + runs them is 9c — until it +lands, an opted-in task is durably enqueued but not executed. + +Default-empty ``WORKER_PG_QUEUE_ENABLED_TASKS`` → everything still routes +to Celery, so this is inert unless a task is explicitly opted in. Design reference: the PG Queue implementation guide in the labs repo (``workflow-execution-architecture``). Branch and section pointers move, @@ -28,9 +31,12 @@ from .client import PgQueueClient, QueueMessage from .connection import create_pg_connection +from .task_payload import TaskPayload, to_payload __all__ = [ "PgQueueClient", "QueueMessage", + "TaskPayload", "create_pg_connection", + "to_payload", ] diff --git a/workers/queue_backend/pg_queue/task_payload.py b/workers/queue_backend/pg_queue/task_payload.py new file mode 100644 index 0000000000..3a7cc5e7fd --- /dev/null +++ b/workers/queue_backend/pg_queue/task_payload.py @@ -0,0 +1,53 @@ +"""Payload carried *inside* a PG queue row, for a dispatched task. + +Not to be confused with the queue **row** itself (the backend's +``PgQueueMessage`` model / ``pg_queue_message`` table). This is the shape +of the row's ``message`` JSONB column when the message is a task: a +``dispatch()`` that routes to PG (9b) serialises a :class:`TaskPayload` +into that column, and the consumer poll loop (9c) decodes it and runs the +task. Producer↔consumer contract — keep both sides reading the same keys. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, TypedDict + +from ..fairness import FairnessPayload + +if TYPE_CHECKING: + from ..fairness import FairnessKey + + +class TaskPayload(TypedDict): + """Everything the 9c consumer needs to run a PG-routed task. + + ``fairness`` is the serialised :class:`FairnessKey` + (:class:`~queue_backend.fairness.FairnessPayload`) or ``None`` — the + consumer rebuilds the ``x-fairness-key`` header from it, mirroring the + Celery dispatch path. + """ + + task_name: str + args: list[Any] + kwargs: dict[str, Any] + queue: str | None + fairness: FairnessPayload | None + + +def to_payload( + task_name: str, + *, + args: Sequence[Any] | None = None, + kwargs: Mapping[str, Any] | None = None, + queue: str | None = None, + fairness: FairnessKey | None = None, +) -> TaskPayload: + """Build the JSON-serialisable task payload for the PG queue.""" + return TaskPayload( + task_name=task_name, + args=list(args) if args is not None else [], + kwargs=dict(kwargs) if kwargs is not None else {}, + queue=queue, + fairness=fairness.to_dict() if fairness is not None else None, + ) diff --git a/workers/sample.env b/workers/sample.env index 9d6f6a61fe..8ad7df3612 100644 --- a/workers/sample.env +++ b/workers/sample.env @@ -139,14 +139,14 @@ WORKER_BARRIER_KEY_TTL_SECONDS=21600 # batch's fan-in fires the callback; this is how messages travel. # # A comma-separated allow-list drives a gradual, per-task-type migration -# (NOT a global on/off switch). A task routes to PG if its name is -# listed. Unset / empty (the default) → everything routes to Celery, -# exactly as today. +# (NOT a global on/off switch). A task routes to PG if its name is listed. +# Unset / empty (the default) → everything routes to Celery, zero +# behaviour change. No Flipt server required. # -# Scaffold note: in this release there is no PG consumer yet, so -# PG-selected tasks are logged but still dispatched via Celery. Setting -# this flag is therefore safe to test routing decisions locally with -# zero behaviour change. No Flipt server required. +# ⚠️ A listed task is enqueued to Postgres instead of Celery. It will only +# execute if the PG consumer is running — otherwise the message is durably +# enqueued but never run. Do NOT opt a task in unless the consumer is +# deployed. # # Roll back instantly by removing an entry; flips take effect on worker # restart (same posture as every other WORKER_* env). diff --git a/workers/tests/test_dispatch_pg.py b/workers/tests/test_dispatch_pg.py new file mode 100644 index 0000000000..6c2f974f74 --- /dev/null +++ b/workers/tests/test_dispatch_pg.py @@ -0,0 +1,137 @@ +"""dispatch() PG enqueue (9b) + the ``TaskPayload`` wire shape. + +The mocked routing/handle behaviour lives in ``test_routing.py``; this +file covers the message serialisation contract and an end-to-end +integration check (a PG-selected ``dispatch()`` lands a decodable row in +``pg_queue_message``). The integration test skips if Postgres is +unreachable or unmigrated. +""" + +from __future__ import annotations + +import importlib +import json +import os +from unittest.mock import patch + +import psycopg2 +import pytest +from queue_backend import dispatch +from queue_backend.fairness import FairnessKey, WorkloadType +from queue_backend.pg_queue import PgQueueClient, to_payload +from queue_backend.pg_queue.connection import create_pg_connection +from queue_backend.routing import _ENABLED_TASKS_ENV_VAR as ENABLED_TASKS_ENV + +# ``queue_backend.dispatch`` the attribute is the function (shadows the +# submodule) — import the module explicitly to reach its globals. +dispatch_mod = importlib.import_module("queue_backend.dispatch") + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + monkeypatch.delenv(ENABLED_TASKS_ENV, raising=False) + dispatch_mod._pg_routing_logged.clear() + dispatch_mod._pg_local.client = None + + +# --- to_payload wire shape --- + + +class TestToPayload: + def test_minimal(self): + assert to_payload("t") == { + "task_name": "t", + "args": [], + "kwargs": {}, + "queue": None, + "fairness": None, + } + + def test_full(self): + fairness = FairnessKey( + org_id="o", workload_type=WorkloadType.API, pipeline_priority=7 + ) + payload = to_payload( + "t", args=("a", 1), kwargs={"k": "v"}, queue="q", fairness=fairness + ) + assert payload["args"] == ["a", 1] # tuple → list + assert payload["queue"] == "q" + assert payload["fairness"] == { + "org_id": "o", + "workload_type": "api", + "pipeline_priority": 7, + } + + def test_json_serialisable(self): + fairness = FairnessKey(org_id="o", workload_type=WorkloadType.NON_API) + # Must round-trip through JSON (it's stored in a JSONB column). + json.dumps(to_payload("t", args=[1], kwargs={"a": "b"}, fairness=fairness)) + + +# --- Integration: dispatch() lands a decodable row in pg_queue_message --- + + +def _test_conn(): + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + return create_pg_connection(env_prefix="TEST_DB_") + + +@pytest.fixture +def pg_client(): + try: + conn = _test_conn() + except psycopg2.OperationalError as exc: + pytest.skip(f"Postgres not reachable: {exc}") + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('pg_queue_message')") + if cur.fetchone()[0] is None: + conn.close() + pytest.skip("pg_queue migration not applied (run backend migrate)") + yield PgQueueClient(conn=conn) + conn.rollback() + conn.close() + + +class TestDispatchEnqueueIntegration: + def test_dispatch_lands_decodable_row(self, monkeypatch, pg_client): + queue_name = f"test_dispatch_{os.getpid()}" + monkeypatch.setenv(ENABLED_TASKS_ENV, "leaf_task") + monkeypatch.setattr(dispatch_mod, "_get_pg_client", lambda: pg_client) + fairness = FairnessKey(org_id="org-x", workload_type=WorkloadType.API) + try: + handle = dispatch( + "leaf_task", + args=[1, 2], + kwargs={"k": "v"}, + queue=queue_name, + fairness=fairness, + ) + msgs = pg_client.read(queue_name, vt_seconds=30, qty=10) + assert len(msgs) == 1 + assert str(msgs[0].msg_id) == handle.id + message = msgs[0].message + assert message["task_name"] == "leaf_task" + assert message["args"] == [1, 2] + assert message["kwargs"] == {"k": "v"} + assert message["queue"] == queue_name + assert message["fairness"]["org_id"] == "org-x" + finally: + with pg_client.conn.cursor() as cur: + cur.execute( + "DELETE FROM pg_queue_message WHERE queue_name = %s", (queue_name,) + ) + pg_client.conn.commit() + + def test_celery_dispatch_unaffected(self): + # No opt-in → Celery path; never touches the PG client. + with ( + patch("queue_backend.dispatch.current_app") as mock_app, + patch("queue_backend.dispatch._get_pg_client") as mock_get, + ): + dispatch("some_task", args=["x"], queue="general") + mock_app.send_task.assert_called_once() + mock_get.assert_not_called() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/workers/tests/test_routing.py b/workers/tests/test_routing.py index f8b55473b4..7c3e6447e8 100644 --- a/workers/tests/test_routing.py +++ b/workers/tests/test_routing.py @@ -20,7 +20,7 @@ import importlib import logging -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from queue_backend import QueueBackend, dispatch, select_backend @@ -44,6 +44,7 @@ def _reset_routing_state(monkeypatch): monkeypatch.delenv(ENABLED_TASKS_ENV, raising=False) routing_mod._allow_list_logged = False dispatch_mod._pg_routing_logged.clear() + dispatch_mod._pg_local.client = None # drop the per-thread PG client # --- select_backend() resolution --- @@ -119,79 +120,109 @@ def test_empty_allow_list_logs_nothing(self, caplog): select_backend("x") assert "PG-queue routing enabled" not in caplog.text - def test_pg_selection_emits_routing_log(self, monkeypatch, caplog): - """Pins the dispatch() routing branch: deleting it must fail here.""" - monkeypatch.setenv(ENABLED_TASKS_ENV, "t1") - with ( - patch("queue_backend.dispatch.current_app"), - caplog.at_level(logging.INFO, logger="queue_backend.dispatch"), - ): - dispatch("t1") - assert "PG-queue routing selected" in caplog.text - def test_celery_selection_emits_no_routing_log(self, caplog): - """Negative case — guards against the gate being made unconditional.""" +# --- dispatch() routing behaviour (9b: PG-selected enqueues to Postgres) --- + + +def _mock_pg_client(monkeypatch, *, msg_id=99): + client = MagicMock() + client.send.return_value = msg_id + # Patch on the module object (string target would navigate the shadowing + # ``dispatch`` *function*, not the submodule). + monkeypatch.setattr(dispatch_mod, "_get_pg_client", lambda: client) + return client + + +class TestDispatchRouting: + """Celery path unchanged; a PG-selected task enqueues to Postgres, not Celery.""" + + def test_celery_path_sends_to_celery_and_never_touches_pg(self): with ( - patch("queue_backend.dispatch.current_app"), - caplog.at_level(logging.INFO, logger="queue_backend.dispatch"), + patch("queue_backend.dispatch.current_app") as mock_app, + patch("queue_backend.dispatch._get_pg_client") as mock_get, ): - dispatch("t1") # empty allow-list → CELERY - assert "PG-queue routing selected" not in caplog.text + dispatch("t1", args=["a"], kwargs={"k": "v"}, queue="general") + mock_app.send_task.assert_called_once() + mock_get.assert_not_called() - def test_routing_log_bounded_to_once_per_task(self, monkeypatch, caplog): + def test_pg_selected_enqueues_to_pg_not_celery(self, monkeypatch): + monkeypatch.setenv(ENABLED_TASKS_ENV, "t1") + client = _mock_pg_client(monkeypatch, msg_id=99) + fairness = FairnessKey(org_id="org-1", workload_type=WorkloadType.API) + with patch("queue_backend.dispatch.current_app") as mock_app: + handle = dispatch( + "t1", args=["a", 1], kwargs={"k": "v"}, queue="general", fairness=fairness + ) + mock_app.send_task.assert_not_called() + client.send.assert_called_once() + queue_name, message = client.send.call_args.args + assert queue_name == "general" + assert message["task_name"] == "t1" + assert message["args"] == ["a", 1] + assert message["kwargs"] == {"k": "v"} + assert message["queue"] == "general" + assert message["fairness"]["org_id"] == "org-1" + assert client.send.call_args.kwargs["org_id"] == "org-1" + # Handle satisfies TaskHandle (.id) and carries the msg_id. + assert handle.id == "99" + + def test_pg_default_queue_name_when_unset(self, monkeypatch): + monkeypatch.setenv(ENABLED_TASKS_ENV, "t1") + client = _mock_pg_client(monkeypatch) + dispatch("t1") + assert client.send.call_args.args[0] == "default" + # No fairness → org_id None (client coerces to ""). + assert client.send.call_args.kwargs["org_id"] is None + + def test_pg_enqueue_failure_propagates_and_does_not_fall_back(self, monkeypatch): + """A PG enqueue failure raises — no silent Celery fallback.""" monkeypatch.setenv(ENABLED_TASKS_ENV, "t1") + client = MagicMock() + client.send.side_effect = RuntimeError("db down") + monkeypatch.setattr(dispatch_mod, "_get_pg_client", lambda: client) with ( - patch("queue_backend.dispatch.current_app"), - caplog.at_level(logging.INFO, logger="queue_backend.dispatch"), + patch("queue_backend.dispatch.current_app") as mock_app, + pytest.raises(RuntimeError), ): dispatch("t1") + mock_app.send_task.assert_not_called() # never falls back to Celery + # The routing decision is logged BEFORE the send, so even a failing + # first dispatch announced it (pins the log-ordering property). + assert "t1" in dispatch_mod._pg_routing_logged + + def test_get_pg_client_lazily_inits_and_reuses(self, monkeypatch): + """The per-thread client is constructed once and reused (connection reuse).""" + dispatch_mod._pg_local.client = None + sentinel = object() + ctor = MagicMock(return_value=sentinel) + monkeypatch.setattr(dispatch_mod, "PgQueueClient", ctor) + assert dispatch_mod._get_pg_client() is sentinel + assert dispatch_mod._get_pg_client() is sentinel + ctor.assert_called_once() + + +class TestCutoverLog: + """The PG cutover log: visible (INFO), once per task name.""" + + def test_pg_enqueue_logs_once_per_task(self, monkeypatch, caplog): + monkeypatch.setenv(ENABLED_TASKS_ENV, "t1") + _mock_pg_client(monkeypatch) + with caplog.at_level(logging.INFO, logger="queue_backend.dispatch"): + dispatch("t1") dispatch("t1") dispatch("t1") - hits = [r for r in caplog.records if "PG-queue routing selected" in r.getMessage()] + hits = [ + r for r in caplog.records if "routing task=" in r.getMessage() + ] assert len(hits) == 1 - -# --- dispatch() is inert under routing (the scaffold invariant) --- - - -class TestDispatchByteIdenticalRegardlessOfRouting: - """``dispatch()`` produces the same send_task call whether routed PG or Celery.""" - - def _capture_send_task(self, task_name, fairness): - with patch("queue_backend.dispatch.current_app") as mock_app: - dispatch( - task_name, - args=["a", 1], - kwargs={"k": "v"}, - queue="general", - fairness=fairness, - ) - return mock_app.send_task.call_args - - def test_pg_selection_does_not_change_the_wire(self, monkeypatch): - fairness = FairnessKey(org_id="org-1", workload_type=WorkloadType.API) - - # Celery path (empty table). - celery_call = self._capture_send_task("t1", fairness) - - # PG path (task opted in) — same inputs. - monkeypatch.setenv(ENABLED_TASKS_ENV, "t1") - pg_call = self._capture_send_task("t1", fairness) - - assert celery_call == pg_call - assert pg_call.args[0] == "t1" - assert pg_call.kwargs["args"] == ["a", 1] - assert pg_call.kwargs["kwargs"] == {"k": "v"} - assert pg_call.kwargs["queue"] == "general" - - def test_dispatch_without_fairness_still_routes_by_task(self, monkeypatch): - """Routing keys on task name only; fairness is irrelevant to the decision.""" - monkeypatch.setenv(ENABLED_TASKS_ENV, "bare_task") - with patch("queue_backend.dispatch.current_app") as mock_app: - dispatch("bare_task") - # Still dispatched (via Celery), header is None (no fairness). - assert mock_app.send_task.call_args.args[0] == "bare_task" - assert mock_app.send_task.call_args.kwargs["headers"] is None + def test_celery_path_emits_no_pg_log(self, caplog): + with ( + patch("queue_backend.dispatch.current_app"), + caplog.at_level(logging.INFO, logger="queue_backend.dispatch"), + ): + dispatch("t1") # empty allow-list → Celery + assert "routing task=" not in caplog.text if __name__ == "__main__": From 12631c1025e2f9a58a3ebcee4e53d4604dcc45af Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:19:45 +0530 Subject: [PATCH 05/65] =?UTF-8?q?UN-3539=20[FEAT]=20PG=20Queue=20Phase=209?= =?UTF-8?q?c=20=E2=80=94=20consumer=20poll=20loop=20(claim=20=E2=86=92=20r?= =?UTF-8?q?un=20=E2=86=92=20ack)=20(#2045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3539 [FEAT] PG Queue Phase 9c — consumer poll loop (claim → run → ack) PgQueueConsumer drains pg_queue_message and runs each claimed task in-process: poll_once() claims a batch (SKIP LOCKED + vt via PgQueueClient.read), runs it via current_app.tasks[name].apply(throw=True), and acks by deleting on success. Task failure -> leave the row (vt expiry redelivers, at-least-once); unknown task -> drop + error (no poison loop). run() adds an empty-queue backoff loop + SIGTERM/SIGINT graceful stop; main() is a `python -m` entrypoint (env-configured). Completes the leaf-first end-to-end path: 8a route -> 9b enqueue -> 9a store -> 9c consume+run. Validated live on the dev stack: a real send_webhook_notification routed to PG was claimed by the consumer, POSTed to Slack (HTTP 200), and acked (row removed). Deployment note: the consumer PROCESS must bootstrap the worker app (import the task modules) so current_app.tasks resolves the task — an entrypoint/rollout concern, not consumer logic. Documented in the module docstring; the rollout phase wires a consumer container that boots the app. Tests: 5 unit (run+ack, fail->no-ack, unknown->drop, empty, graceful stop) + 1 real-PG integration (enqueue -> poll -> execute -> ack). Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3539 [FIX] Dedup PG integration test fixtures into conftest (SonarCloud) The connect-to-dev-DB + skip-if-unreachable/unmigrated block was copy-pasted across test_pg_queue_client / test_dispatch_pg / test_pg_queue_consumer (6.3% duplication on new code, over the 3% gate). Extracted into shared pg_conn / pg_client fixtures + an integration_pg_conn() helper in tests/conftest.py; the three files now use them. Behaviour unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3539 [FIX] Address Phase 9c review feedback - fairness header rebuilt from the payload on the PG run path (was dropped) — a PG-routed run now mirrors the Celery dispatch contract. - poison-message guard: surface read_ct (QueueMessage + dequeue RETURNING), drop a task that keeps failing past max_attempts (default 5, env WORKER_PG_QUEUE_CONSUMER_MAX_ATTEMPTS) with a loud ERROR carrying the payload, instead of redelivering forever. - malformed payload (missing task_name) -> distinct "missing task_name" drop log, not a misleading "unknown task None". - run() wraps poll_once() so a transient read/DB blip backs off and continues instead of tearing down the loop (the client self-recovers). - ack: WARN when delete() finds no row (task exceeded vt -> possible double-run). - __init__ validates positive tuning params; main() wires backoff_max + max_attempts via env (prefix helper); non-main-thread signal-install failure -> WARNING. - tests: poison drop, missing task_name, fairness-header propagation, multi-message batch, ack-no-row warn, construction validation, backoff growth/reset, poll-error resilience; fixed the read mock for read_ct. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3539 [FIX] Address Greptile review on the 9c consumer - [P1] batch shared-vt window: default batch_size 10 -> 1 so each message gets its own visibility window. The batch's vt is set atomically at claim but messages run sequentially, so with batch_size>1 the tail could exceed vt and be re-claimed mid-run (double-run). Batching stays opt-in; doc the vt > batch x worst-case-duration constraint. - [P2] QueueMessage.read_ct: drop the misleading =0 default (a "never claimed" state the dequeue can't produce — read_ct is always >=1). 0 would silently bypass the poison guard; now required, all callers supply it. - [P2] __init__: reject backoff_max < poll_interval (else min(poll*2, max) shrinks the backoff below poll_interval instead of growing). - [P2] dedup the ack-miss warning: client.delete() now logs at DEBUG; the consumer keeps the contextual WARNING (it names the task). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- workers/queue_backend/pg_queue/client.py | 23 +- workers/queue_backend/pg_queue/consumer.py | 243 +++++++++++++++++++++ workers/tests/conftest.py | 44 ++++ workers/tests/test_dispatch_pg.py | 26 +-- workers/tests/test_pg_queue_client.py | 37 +--- workers/tests/test_pg_queue_consumer.py | 238 ++++++++++++++++++++ 6 files changed, 547 insertions(+), 64 deletions(-) create mode 100644 workers/queue_backend/pg_queue/consumer.py create mode 100644 workers/tests/test_pg_queue_consumer.py diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py index fb61147b8c..030f8d8921 100644 --- a/workers/queue_backend/pg_queue/client.py +++ b/workers/queue_backend/pg_queue/client.py @@ -68,7 +68,7 @@ FOR UPDATE SKIP LOCKED LIMIT %s ) -RETURNING msg_id, message +RETURNING msg_id, message, read_ct """ @@ -79,11 +79,15 @@ class QueueMessage: ``message`` is the already-decoded JSONB payload (psycopg2 parses ``jsonb`` to a Python ``dict``). ``frozen`` freezes the binding only — the ``dict`` itself is mutable, so treat the payload as read-only by - convention. + convention. ``read_ct`` is the post-claim delivery count — always ``>= 1`` + from the dequeue (``read_ct = read_ct + 1 RETURNING``), so consumers can + cap redelivery of a poison message. No default: a ``0`` ("never claimed") + can't come from the dequeue and would silently bypass the poison guard. """ msg_id: int message: dict[str, Any] + read_ct: int class PgQueueClient: @@ -180,23 +184,26 @@ def read( with self._cursor() as cur: cur.execute(_DEQUEUE_SQL, (vt_seconds, queue_name, qty)) rows = cur.fetchall() - return [QueueMessage(msg_id=int(r[0]), message=r[1]) for r in rows] + return [ + QueueMessage(msg_id=int(r[0]), message=r[1], read_ct=int(r[2])) for r in rows + ] def delete(self, msg_id: int) -> bool: """Ack a processed message. Returns ``True`` if a row was removed. ``False`` means the row was already gone — typically its visibility timeout expired during processing and another worker (re)claimed it, - i.e. the work may be processed twice. Logged at WARNING so this - at-least-once condition is visible rather than silently swallowed. + i.e. the work may be processed twice. Logged at DEBUG here; the + consumer emits the contextual WARNING (it names the task), so this + avoids a duplicate warning per double-run. """ with self._cursor() as cur: cur.execute("DELETE FROM pg_queue_message WHERE msg_id = %s", (msg_id,)) deleted = cur.rowcount if deleted == 0: - logger.warning( - "PG-queue: delete(msg_id=%s) removed no row — message was already " - "gone (vt likely expired during processing; possible re-delivery).", + logger.debug( + "PG-queue: delete(msg_id=%s) removed no row — already gone " + "(vt likely expired; consumer logs the contextual warning).", msg_id, ) return deleted == 1 diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py new file mode 100644 index 0000000000..a5c2024201 --- /dev/null +++ b/workers/queue_backend/pg_queue/consumer.py @@ -0,0 +1,243 @@ +"""PG queue consumer — claims tasks from ``pg_queue_message`` and runs them. + +The producer side (9b) enqueues a :class:`~queue_backend.pg_queue.TaskPayload` +when a task is routed to PG. This is the other half: it polls the queue with +``SKIP LOCKED`` + a visibility timeout (via :class:`PgQueueClient`), runs each +claimed task **in-process** (no Celery broker), and acks by deleting the row. + +A task that fails — or a crash before ack — is redelivered once its ``vt`` +expires (at-least-once; tasks must be idempotent), bounded by ``max_attempts`` +(``read_ct``): a task that keeps failing past the cap is dropped as a poison +message (logged with its payload) rather than redelivered forever. A message +with no ``task_name`` (malformed/foreign) or a name not in the registry can +never run, so it is likewise dropped with a loud log. The fairness header is +rebuilt from the payload so a PG-routed run mirrors the Celery dispatch path. + +Run as ``python -m queue_backend.pg_queue.consumer`` (config via env). The +worker bootstrap must have imported/registered the Celery tasks so they +resolve in ``current_app.tasks``. +""" + +from __future__ import annotations + +import logging +import os +import signal +import time +from typing import TYPE_CHECKING + +from celery import current_app + +from ..fairness import FAIRNESS_HEADER_NAME +from .client import PgQueueClient + +if TYPE_CHECKING: + from celery import Celery + + from .client import QueueMessage + +logger = logging.getLogger(__name__) + +_DEFAULT_QUEUE = "default" +# Default 1: the whole batch shares one vt window (set atomically at claim), +# but messages run sequentially — so with batch_size > 1 the tail can exceed +# its vt and be re-claimed mid-run (double-run). Batching is opt-in; if you +# raise it, keep vt_seconds > batch_size x worst-case task duration. +_DEFAULT_BATCH = 1 +_DEFAULT_VT_SECONDS = 30 +_DEFAULT_POLL_INTERVAL = 0.1 +_DEFAULT_BACKOFF_MAX = 2.0 +# A task claimed more than this many times keeps failing — drop it (poison) +# rather than redeliver forever. +_DEFAULT_MAX_ATTEMPTS = 5 + + +class PgQueueConsumer: + """Polls one PG queue, runs each claimed task in-process, acks on success.""" + + def __init__( + self, + queue_name: str, + *, + client: PgQueueClient | None = None, + app: Celery | None = None, + batch_size: int = _DEFAULT_BATCH, + vt_seconds: int = _DEFAULT_VT_SECONDS, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + backoff_max: float = _DEFAULT_BACKOFF_MAX, + max_attempts: int = _DEFAULT_MAX_ATTEMPTS, + ) -> None: + # Validate at construction so a misconfigured consumer fails here + # rather than batch-after-batch once the loop starts. + for name, value in ( + ("batch_size", batch_size), + ("vt_seconds", vt_seconds), + ("poll_interval", poll_interval), + ("backoff_max", backoff_max), + ("max_attempts", max_attempts), + ): + if value <= 0: + raise ValueError(f"{name} must be positive, got {value!r}") + if backoff_max < poll_interval: + # Otherwise min(poll_interval*2, backoff_max) shrinks the backoff + # below poll_interval — it would decrease instead of grow. + raise ValueError( + f"backoff_max ({backoff_max}) must be >= poll_interval " + f"({poll_interval})" + ) + self.queue_name = queue_name + self._client = client if client is not None else PgQueueClient() + self._app = app if app is not None else current_app + self.batch_size = batch_size + self.vt_seconds = vt_seconds + self.poll_interval = poll_interval + self.backoff_max = backoff_max + self.max_attempts = max_attempts + self._running = False + + def poll_once(self) -> int: + """Claim + process one batch; returns the number of messages claimed.""" + messages = self._client.read( + self.queue_name, vt_seconds=self.vt_seconds, qty=self.batch_size + ) + for message in messages: + self._handle(message) + return len(messages) + + def _handle(self, message: QueueMessage) -> None: + payload = message.message + task_name = payload.get("task_name") + + # Malformed / foreign payload: no task name → can't run; drop with a + # log that points at the payload, not at task registration. + if not task_name: + logger.error( + "PG-queue consumer: payload missing task_name (msg_id=%s) — " + "dropping malformed message: %r", + message.msg_id, + payload, + ) + self._client.delete(message.msg_id) + return + + # Poison message: a task re-claimed past the cap keeps failing. Drop + # it (with the payload, so it's recoverable from logs) instead of + # redelivering on every vt expiry forever. + if message.read_ct > self.max_attempts: + logger.error( + "PG-queue consumer: task %r (msg_id=%s) exceeded max_attempts=%s " + "(read_ct=%s) — dropping poison message: %r", + task_name, + message.msg_id, + self.max_attempts, + message.read_ct, + payload, + ) + self._client.delete(message.msg_id) + return + + task = self._app.tasks.get(task_name) + if task is None: + # A named-but-unregistered task can never run → drop and shout. + logger.error( + "PG-queue consumer: unknown task %r (msg_id=%s) — dropping", + task_name, + message.msg_id, + ) + self._client.delete(message.msg_id) + return + + try: + # Run the task body in-process (eager), carrying the fairness + # header so a PG-routed run mirrors the Celery dispatch path. + fairness = payload.get("fairness") + headers = {FAIRNESS_HEADER_NAME: fairness} if fairness else None + task.apply( + args=payload.get("args") or [], + kwargs=payload.get("kwargs") or {}, + headers=headers, + throw=True, + ) + except Exception: + # Leave the row: its vt expires and it is redelivered (bounded by + # max_attempts above). + logger.exception( + "PG-queue consumer: task %r (msg_id=%s, read_ct=%s) failed — " + "leaving for vt-expiry redelivery", + task_name, + message.msg_id, + message.read_ct, + ) + return + + if not self._client.delete(message.msg_id): # ack + logger.warning( + "PG-queue consumer: ack found no row for task %r (msg_id=%s) — " + "it likely exceeded vt and was re-claimed (possible double-run)", + task_name, + message.msg_id, + ) + + def run(self, *, install_signals: bool = True) -> None: + """Poll loop with empty-queue backoff and graceful shutdown.""" + self._running = True + if install_signals: + self._install_signal_handlers() + logger.info( + "PG-queue consumer started (queue=%r, batch=%s, vt=%ss)", + self.queue_name, + self.batch_size, + self.vt_seconds, + ) + backoff = self.poll_interval + while self._running: + try: + claimed = self.poll_once() + except Exception: + # A transient read/DB blip must not tear down the loop — the + # client self-recovers its connection, so log and back off. + logger.exception( + "PG-queue consumer: poll cycle failed; backing off and continuing" + ) + claimed = 0 + if claimed: + backoff = self.poll_interval + else: + time.sleep(backoff) + backoff = min(backoff * 2, self.backoff_max) + logger.info("PG-queue consumer stopped (queue=%r)", self.queue_name) + + def stop(self, *_: object) -> None: + """Request a graceful stop after the current batch.""" + self._running = False + + def _install_signal_handlers(self) -> None: + # signal.signal only works in the main thread. + try: + signal.signal(signal.SIGTERM, self.stop) + signal.signal(signal.SIGINT, self.stop) + except ValueError: + logger.warning( + "PG-queue consumer: signal handlers not installed (non-main " + "thread) — SIGTERM/SIGINT will not trigger graceful shutdown" + ) + + +def main() -> None: + logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) + + def _env(suffix: str, default: object, cast: type) -> object: + return cast(os.getenv(f"WORKER_PG_QUEUE_CONSUMER_{suffix}", default)) + + PgQueueConsumer( + queue_name=_env("QUEUE", _DEFAULT_QUEUE, str), + batch_size=_env("BATCH", _DEFAULT_BATCH, int), + vt_seconds=_env("VT_SECONDS", _DEFAULT_VT_SECONDS, int), + poll_interval=_env("POLL_INTERVAL", _DEFAULT_POLL_INTERVAL, float), + backoff_max=_env("BACKOFF_MAX", _DEFAULT_BACKOFF_MAX, float), + max_attempts=_env("MAX_ATTEMPTS", _DEFAULT_MAX_ATTEMPTS, int), + ).run() + + +if __name__ == "__main__": + main() diff --git a/workers/tests/conftest.py b/workers/tests/conftest.py index 084a8ef88c..2939c1aa89 100644 --- a/workers/tests/conftest.py +++ b/workers/tests/conftest.py @@ -6,9 +6,53 @@ time if INTERNAL_API_BASE_URL is not set. """ +import os from pathlib import Path +import psycopg2 +import pytest from dotenv import load_dotenv _env_test = Path(__file__).resolve().parent.parent / ".env.test" load_dotenv(_env_test) + + +# --- Shared PG-queue integration fixtures (real Postgres) --- +# +# Connect to the dev DB via TEST_DB_* — NOT the generic DB_*, which the +# suite's unit isolation sets to DB_USER=test. Skip gracefully when Postgres +# is unreachable or the pg_queue migration hasn't been applied, so CI without +# a migrated DB doesn't fail (only the real-DB seam is gated). + + +def integration_pg_conn(): + """Raw psycopg2 connection to the dev DB (host defaults to localhost).""" + from queue_backend.pg_queue.connection import create_pg_connection + + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + return create_pg_connection(env_prefix="TEST_DB_") + + +@pytest.fixture +def pg_conn(): + """A real connection; skips if Postgres is unreachable or unmigrated.""" + try: + conn = integration_pg_conn() + except psycopg2.OperationalError as exc: + pytest.skip(f"Postgres not reachable: {exc}") + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('pg_queue_message')") + if cur.fetchone()[0] is None: + conn.close() + pytest.skip("pg_queue migration not applied (run backend migrate)") + yield conn + conn.rollback() + conn.close() + + +@pytest.fixture +def pg_client(pg_conn): + """A :class:`PgQueueClient` over the integration connection.""" + from queue_backend.pg_queue import PgQueueClient + + return PgQueueClient(conn=pg_conn) diff --git a/workers/tests/test_dispatch_pg.py b/workers/tests/test_dispatch_pg.py index 6c2f974f74..75d840962f 100644 --- a/workers/tests/test_dispatch_pg.py +++ b/workers/tests/test_dispatch_pg.py @@ -14,12 +14,10 @@ import os from unittest.mock import patch -import psycopg2 import pytest from queue_backend import dispatch from queue_backend.fairness import FairnessKey, WorkloadType -from queue_backend.pg_queue import PgQueueClient, to_payload -from queue_backend.pg_queue.connection import create_pg_connection +from queue_backend.pg_queue import to_payload from queue_backend.routing import _ENABLED_TASKS_ENV_VAR as ENABLED_TASKS_ENV # ``queue_backend.dispatch`` the attribute is the function (shadows the @@ -69,27 +67,7 @@ def test_json_serialisable(self): # --- Integration: dispatch() lands a decodable row in pg_queue_message --- - - -def _test_conn(): - os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") - return create_pg_connection(env_prefix="TEST_DB_") - - -@pytest.fixture -def pg_client(): - try: - conn = _test_conn() - except psycopg2.OperationalError as exc: - pytest.skip(f"Postgres not reachable: {exc}") - with conn.cursor() as cur: - cur.execute("SELECT to_regclass('pg_queue_message')") - if cur.fetchone()[0] is None: - conn.close() - pytest.skip("pg_queue migration not applied (run backend migrate)") - yield PgQueueClient(conn=conn) - conn.rollback() - conn.close() +# Uses the shared ``pg_client`` fixture from conftest.py. class TestDispatchEnqueueIntegration: diff --git a/workers/tests/test_pg_queue_client.py b/workers/tests/test_pg_queue_client.py index fbc4bcd579..20bb72a4ba 100644 --- a/workers/tests/test_pg_queue_client.py +++ b/workers/tests/test_pg_queue_client.py @@ -69,14 +69,14 @@ def test_send_coerces_missing_org_to_empty_string(self): assert params[2] == "" def test_read_runs_skip_locked_dequeue(self): - conn, cur = _mock_conn(fetchall=[(7, {"k": "v"})]) + conn, cur = _mock_conn(fetchall=[(7, {"k": "v"}, 1)]) msgs = PgQueueClient(conn=conn).read("q1", vt_seconds=15, qty=3) sql, params = cur.execute.call_args.args assert "FOR UPDATE SKIP LOCKED" in sql assert "UPDATE pg_queue_message" in sql # Param order follows the %s positions: vt_seconds, queue_name, qty. assert params == (15, "q1", 3) - assert msgs == [QueueMessage(msg_id=7, message={"k": "v"})] + assert msgs == [QueueMessage(msg_id=7, message={"k": "v"}, read_ct=1)] conn.commit.assert_called_once() def test_delete_returns_true_when_row_removed(self): @@ -231,35 +231,7 @@ def boom(**_): assert "failed to connect" in caplog.text -# --- Integration: real Postgres --- - - -def _integration_conn(): - # Reuse create_pg_connection (single source of connection logic) via the - # dedicated TEST_DB_* prefix — NOT the generic DB_*, which the suite's - # conftest sets to DB_USER=test for unit isolation (wrong real-DB creds). - # Default the host to the dev-compose published port on localhost. - os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") - return create_pg_connection(env_prefix="TEST_DB_") - - -@pytest.fixture -def pg_conn(): - try: - conn = _integration_conn() - except psycopg2.OperationalError as exc: - # Only an unreachable/unauthenticated DB skips — ImportError, bugs, - # and schema/permission errors surface as real failures. - pytest.skip(f"Postgres not reachable: {exc}") - with conn.cursor() as cur: - cur.execute("SELECT to_regclass('pg_queue_message')") - (table,) = cur.fetchone() - if table is None: - conn.close() - pytest.skip("pg_queue migration not applied (run backend migrate)") - yield conn - conn.rollback() - conn.close() +# --- Integration: real Postgres (pg_conn fixture from conftest.py) --- @pytest.fixture @@ -306,7 +278,8 @@ def test_no_double_delivery_across_readers(self, pg_conn, queue_name): client_a = PgQueueClient(conn=pg_conn) for i in range(5): client_a.send(queue_name, {"i": i}) - conn_b = _integration_conn() + # Second connection (TEST_DB_HOST already set by the pg_conn fixture). + conn_b = create_pg_connection(env_prefix="TEST_DB_") try: client_b = PgQueueClient(conn=conn_b) ids_a = {m.msg_id for m in client_a.read(queue_name, vt_seconds=30, qty=3)} diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py new file mode 100644 index 0000000000..02e7d9c9cd --- /dev/null +++ b/workers/tests/test_pg_queue_consumer.py @@ -0,0 +1,238 @@ +"""Tests for the PG queue consumer (claim → run → ack). + +Unit tests drive ``poll_once`` with a mocked client and *real* registered +Celery tasks (so ``apply()`` actually runs the body). The integration test +exercises the full enqueue → poll → execute → ack loop against real Postgres +(skips if unreachable / unmigrated). +""" + +from __future__ import annotations + +import logging +import os +from unittest.mock import MagicMock + +import pytest +from celery import shared_task +from queue_backend.fairness import FAIRNESS_HEADER_NAME +from queue_backend.pg_queue import to_payload +from queue_backend.pg_queue.client import QueueMessage +from queue_backend.pg_queue.consumer import PgQueueConsumer + +# Registered test tasks (namespaced). apply() runs their bodies in-process. +_calls: list = [] + + +@shared_task(name="test_pg_consumer.ok") +def _ok_task(x, y=0): + _calls.append((x, y)) + return x + y + + +@shared_task(name="test_pg_consumer.boom") +def _boom_task(): + raise RuntimeError("boom") + + +@pytest.fixture(autouse=True) +def _clear_calls(): + _calls.clear() + + +def _msg(msg_id, payload, *, read_ct=1): + return QueueMessage(msg_id=msg_id, message=payload, read_ct=read_ct) + + +def _ok_payload(x, y=0): + return {"task_name": "test_pg_consumer.ok", "args": [x], "kwargs": {"y": y}} + + +# --- poll_once (mocked client, real tasks) --- + + +class TestPollOnce: + def test_runs_task_and_acks(self): + client = MagicMock() + client.read.return_value = [_msg(1, _ok_payload(3, 4))] + assert PgQueueConsumer("q", client=client).poll_once() == 1 + assert _calls == [(3, 4)] # task body ran + client.delete.assert_called_once_with(1) # acked + + def test_failed_task_is_not_acked_and_logs(self, caplog): + client = MagicMock() + client.read.return_value = [ + _msg(2, {"task_name": "test_pg_consumer.boom", "args": [], "kwargs": {}}) + ] + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): + PgQueueConsumer("q", client=client).poll_once() + client.delete.assert_not_called() # left for vt-expiry redelivery + assert "failed" in caplog.text # the cycling signal is logged + + def test_unknown_task_is_dropped(self, caplog): + client = MagicMock() + client.read.return_value = [ + _msg(3, {"task_name": "nope.nope", "args": [], "kwargs": {}}) + ] + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): + PgQueueConsumer("q", client=client).poll_once() + client.delete.assert_called_once_with(3) # dropped, not redelivered + assert "unknown task" in caplog.text + + def test_missing_task_name_dropped_as_malformed(self, caplog): + client = MagicMock() + client.read.return_value = [_msg(4, {"args": [], "kwargs": {}})] # no task_name + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): + PgQueueConsumer("q", client=client).poll_once() + client.delete.assert_called_once_with(4) + assert "missing task_name" in caplog.text # distinct from "unknown task" + + def test_poison_message_dropped_past_max_attempts(self, caplog): + client = MagicMock() + # boom task, claimed more than max_attempts times → drop instead of redeliver. + client.read.return_value = [ + _msg(5, {"task_name": "test_pg_consumer.boom"}, read_ct=6) + ] + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): + PgQueueConsumer("q", client=client, max_attempts=5).poll_once() + client.delete.assert_called_once_with(5) # dropped as poison + assert "poison" in caplog.text + + def test_fairness_header_rebuilt_for_run(self): + # Mock app so we can inspect the apply() headers. + fairness = {"org_id": "o", "workload_type": "api", "pipeline_priority": 5} + task = MagicMock() + app = MagicMock() + app.tasks.get.return_value = task + client = MagicMock() + client.read.return_value = [ + _msg(6, {"task_name": "t", "args": [1], "kwargs": {"k": "v"}, "fairness": fairness}) + ] + PgQueueConsumer("q", client=client, app=app).poll_once() + kwargs = task.apply.call_args.kwargs + assert kwargs["args"] == [1] + assert kwargs["kwargs"] == {"k": "v"} + assert kwargs["headers"] == {FAIRNESS_HEADER_NAME: fairness} + + def test_ack_finding_no_row_warns(self, caplog): + client = MagicMock() + client.delete.return_value = False # row already gone (vt expired mid-run) + client.read.return_value = [_msg(7, _ok_payload(1))] + with caplog.at_level(logging.WARNING, logger="queue_backend.pg_queue.consumer"): + PgQueueConsumer("q", client=client).poll_once() + assert "possible double-run" in caplog.text + + def test_multi_message_batch(self): + client = MagicMock() + client.read.return_value = [ + _msg(10, _ok_payload(1)), + _msg(11, {"task_name": "test_pg_consumer.boom"}), + _msg(12, {"task_name": "nope.nope"}), + _msg(13, _ok_payload(2)), + ] + assert PgQueueConsumer("q", client=client).poll_once() == 4 + assert _calls == [(1, 0), (2, 0)] # ok tasks ran in order + deleted = {c.args[0] for c in client.delete.call_args_list} + assert deleted == {10, 12, 13} # ok acked + unknown dropped; boom NOT acked + + def test_empty_batch_acks_nothing(self): + client = MagicMock() + client.read.return_value = [] + assert PgQueueConsumer("q", client=client).poll_once() == 0 + client.delete.assert_not_called() + + +class TestConstruction: + def test_rejects_non_positive_params(self): + for kw in ( + {"batch_size": 0}, + {"vt_seconds": -1}, + {"poll_interval": 0}, + {"backoff_max": 0}, + {"max_attempts": 0}, + ): + with pytest.raises(ValueError): + PgQueueConsumer("q", client=MagicMock(), **kw) + + def test_rejects_backoff_max_below_poll_interval(self): + # Otherwise backoff would shrink below poll_interval instead of growing. + with pytest.raises(ValueError, match="backoff_max"): + PgQueueConsumer( + "q", client=MagicMock(), poll_interval=0.5, backoff_max=0.1 + ) + + +class TestRunLoop: + def test_run_stops_gracefully(self, monkeypatch): + client = MagicMock() + client.read.return_value = [] # always empty → backoff path + consumer = PgQueueConsumer("q", client=client) + # First empty poll → sleep (patched to request stop) → loop exits. + monkeypatch.setattr( + "queue_backend.pg_queue.consumer.time.sleep", lambda _s: consumer.stop() + ) + consumer.run(install_signals=False) + assert consumer._running is False + + def test_backoff_grows_then_resets(self, monkeypatch): + client = MagicMock() + # empty, empty, one message, empty → backoff doubles, resets, doubles. + client.read.side_effect = [[], [], [_msg(1, _ok_payload(1))], []] + consumer = PgQueueConsumer( + "q", client=client, poll_interval=0.1, backoff_max=0.25 + ) + sleeps: list[float] = [] + + def _sleep(secs): + sleeps.append(secs) + if len(sleeps) == 3: # stop after the third sleep + consumer.stop() + + monkeypatch.setattr("queue_backend.pg_queue.consumer.time.sleep", _sleep) + consumer.run(install_signals=False) + # empty→0.1, empty→0.2 (doubled), [msg] resets, empty→0.1 again. + assert sleeps == [0.1, 0.2, 0.1] + + def test_poll_error_does_not_kill_loop(self, monkeypatch): + client = MagicMock() + # first poll raises (transient), then empty → loop must survive the raise. + client.read.side_effect = [RuntimeError("blip"), []] + consumer = PgQueueConsumer("q", client=client) + sleeps: list[float] = [] + + def _sleep(secs): + sleeps.append(secs) + if len(sleeps) == 2: # stop after the post-error poll + consumer.stop() + + monkeypatch.setattr("queue_backend.pg_queue.consumer.time.sleep", _sleep) + consumer.run(install_signals=False) # must not raise + assert client.read.call_count == 2 # kept polling after the error + + +# --- Integration: full enqueue → poll → execute → ack against real PG --- +# Uses the shared ``pg_client`` fixture from conftest.py. + + +class TestConsumerIntegration: + def test_enqueue_poll_execute_ack(self, pg_client): + queue_name = f"test_consumer_{os.getpid()}" + try: + pg_client.send( + queue_name, + to_payload("test_pg_consumer.ok", args=[5], kwargs={"y": 6}), + ) + claimed = PgQueueConsumer(queue_name, client=pg_client).poll_once() + assert claimed == 1 + assert (5, 6) in _calls # task actually executed off Postgres + # Row was acked (deleted) — nothing left to claim. + assert pg_client.read(queue_name, vt_seconds=30, qty=10) == [] + finally: + with pg_client.conn.cursor() as cur: + cur.execute( + "DELETE FROM pg_queue_message WHERE queue_name = %s", (queue_name,) + ) + pg_client.conn.commit() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From b58ee1e3247acab4fff78dd6080d5c59711b63f6 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:45:53 +0530 Subject: [PATCH 06/65] UN-3541 [FEAT] Wire PG-queue consumer into run-worker.sh + bootstrap guard (#2047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3541 [FEAT] Wire PG-queue consumer into run-worker.sh + bootstrap guard Make the 9c PG-queue consumer (UN-3539) runnable via the normal worker flow, safely. Split from #2045 to keep that PR focused on consumer logic. - Launcher (pg_queue_consumer/__main__.py): set WORKER_TYPE to the source worker (default notification) BEFORE `import worker`, so the right tasks register. worker.py loads exactly one worker type's tasks; a bare import would load the general worker's and drop every notification as unknown. - run-worker.sh: `pg-queue-consumer` type runs `python -m pg_queue_consumer` (not a celery command) from the workers root; queue via env. - Startup guard: PgQueueConsumer.run() refuses to start on an empty task registry — fail loud instead of silently dropping every message. - Drop the hard-coded 8086 health port (consumer runs no health server). Integration fixes found during live dev-test (real send_webhook_notification end-to-end → Slack HTTP 200): - Opt-in status: consumer is not part of `all`; shown only when running. - Log path: detach writes to an absolute $worker_dir/$type.log so -L/-C find it (also fixes the same latent bug for pluggable workers). - PID discovery: get_worker_pids matches the `python -m` invocation, so --status / -k / -r work for the consumer. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3541 [FIX] Address PR #2047 review feedback - run-worker.sh: verify liveness after a detached launch (kill -0 + tail); `set -e` doesn't apply to `&`, so a fork that died on startup was reported as "started" — acute for the health-port-less consumer (High). - consumer.py: log the registered application task names at startup so a *wrong* (non-empty but mismatched) registry is diagnosable — the guard only catches an *empty* one (Medium observability). - consumer.py: type _env() with a TypeVar (was `cast: type -> object`, erasing types at the typed __init__) and name the offending var on a bad cast instead of a context-free ValueError (Medium). - tests: add the guard's positive / require_tasks=False bypass / built-in filter arms (only the failure arm was covered). - get_worker_pids: warn on pgrep rc>1 (operational/regex error) instead of collapsing it to "not running" (Low). - run-worker.sh: extract the repeated "pg_queue_consumer" literal into a readonly constant (SonarCloud S1192). - Comment accuracy: build_celery_app configures but does not import tasks; `notification` is the worker that owns the leaf task, not the task itself; generalise the "every notification dropped" example. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3541 [FIX] Address Greptile review feedback - __main__.py: move the WORKER_TYPE mutation + `import worker` bootstrap into a guarded _bootstrap_and_run() called only under `__name__ == "__main__"`, so an accidental import (test/IDE/type-checker walking __main__) no longer overwrites WORKER_TYPE or triggers a full worker-app bootstrap. - run-worker.sh: list `pg-queue-consumer` in the usage/--help worker types, plus a note for its WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE / _QUEUE overrides. - run-worker.sh: clarify the post-launch liveness check is a best-effort fast-fail for *immediate* (sub-second) crash-on-import/bad-config faults, not a connectivity check; kept general (an immediate crash can hit any worker) and noted the `all` subshells overlap the 1s with inter-launch sleep. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- workers/pg_queue_consumer/__init__.py | 12 +++ workers/pg_queue_consumer/__main__.py | 46 ++++++++++ workers/queue_backend/pg_queue/consumer.py | 55 ++++++++++-- workers/run-worker.sh | 100 +++++++++++++++++++-- workers/tests/test_pg_queue_consumer.py | 57 ++++++++++++ 5 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 workers/pg_queue_consumer/__init__.py create mode 100644 workers/pg_queue_consumer/__main__.py diff --git a/workers/pg_queue_consumer/__init__.py b/workers/pg_queue_consumer/__init__.py new file mode 100644 index 0000000000..24ee4777ae --- /dev/null +++ b/workers/pg_queue_consumer/__init__.py @@ -0,0 +1,12 @@ +"""PG-queue consumer worker — a standalone process that drains ``pg_queue_message``. + +It bootstraps a source worker's Celery app (registering that worker type's +tasks, like ``celery -A worker worker`` for a single type) so the consumer can +resolve and run them, then runs the +:class:`~queue_backend.pg_queue.consumer.PgQueueConsumer` poll loop. The source +worker type is selected by ``WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE`` (default +``notification``); see ``__main__`` for why this must precede ``import worker``. + +Launch via ``python -m pg_queue_consumer`` or ``./run-worker.sh +pg-queue-consumer``. Config via ``WORKER_PG_QUEUE_CONSUMER_*`` env. +""" diff --git a/workers/pg_queue_consumer/__main__.py b/workers/pg_queue_consumer/__main__.py new file mode 100644 index 0000000000..1be588708b --- /dev/null +++ b/workers/pg_queue_consumer/__main__.py @@ -0,0 +1,46 @@ +"""Entry point: bootstrap a worker app's tasks, then run the PG-queue consumer. + +The root ``worker`` module loads exactly ONE worker type's tasks, chosen by the +``WORKER_TYPE`` env: ``worker.py`` builds the Celery app via +``WorkerBuilder.build_celery_app`` (which configures, but does NOT import +tasks), then — in separate module-level logic in ``worker.py`` — ``importlib``- +loads that type's ``tasks.py``. The PG consumer drains a specific source +worker's queue, so it must register THAT worker's tasks: we set ``WORKER_TYPE`` +from ``WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE`` (default ``notification`` — the +worker that owns the first migrated leaf task, ``send_webhook_notification``) +BEFORE importing ``worker``. + +This override is required: ``run-worker.sh`` exports its own ``WORKER_TYPE`` +(the consumer pseudo-type ``pg_queue_consumer``, which owns no tasks), so a +plain import would fall back to the ``general`` worker's tasks and every +message for the intended worker would be dropped as an unknown task. The +consumer's startup guard only catches an *empty* registry, not a *wrong* one — +so the right worker type must be selected here. + +Launch via ``python -m pg_queue_consumer`` or ``./run-worker.sh pg-queue-consumer``. +""" + +import os + + +def _bootstrap_and_run() -> None: + # Select the source worker whose tasks back this consumer's queue. Must run + # BEFORE `import worker`, which reads WORKER_TYPE at import time. We overwrite + # (not setdefault) because the launcher's own WORKER_TYPE owns no tasks. + # + # Kept inside this guarded function (not at module scope) so the env mutation + # and the heavy `import worker` bootstrap only happen on the `python -m` + # entry — never as a side-effect if this module is imported by a test, IDE, + # or type-checker walking `__main__` files. + os.environ["WORKER_TYPE"] = os.environ.get( + "WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE", "notification" + ) + + import worker # noqa: F401 — side-effect: registers the source worker's tasks + from queue_backend.pg_queue.consumer import main + + main() + + +if __name__ == "__main__": + _bootstrap_and_run() diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index a5c2024201..d7f527f705 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -24,7 +24,8 @@ import os import signal import time -from typing import TYPE_CHECKING +from collections.abc import Callable +from typing import TYPE_CHECKING, TypeVar from celery import current_app @@ -38,6 +39,8 @@ logger = logging.getLogger(__name__) +_T = TypeVar("_T") + _DEFAULT_QUEUE = "default" # Default 1: the whole batch shares one vt window (set atomically at claim), # but messages run sequentially — so with batch_size > 1 the tail can exceed @@ -178,16 +181,46 @@ def _handle(self, message: QueueMessage) -> None: message.msg_id, ) - def run(self, *, install_signals: bool = True) -> None: - """Poll loop with empty-queue backoff and graceful shutdown.""" + def _registered_task_count(self) -> int: + """Count application tasks (excluding Celery's built-ins).""" + return sum(1 for name in self._app.tasks if not name.startswith("celery.")) + + def run(self, *, install_signals: bool = True, require_tasks: bool = True) -> None: + """Poll loop with empty-queue backoff and graceful shutdown. + + Refuses to start if no application tasks are registered — a strong + signal the worker app wasn't bootstrapped, in which case *every* + message would be dropped as "unknown task". This makes a + misconfigured launch fail loudly instead of silently destroying data. + """ + if require_tasks and self._registered_task_count() == 0: + raise RuntimeError( + "PG-queue consumer: no application tasks are registered — the " + "worker app was not bootstrapped. Launch via " + "`python -m pg_queue_consumer` (or ./run-worker.sh " + "pg-queue-consumer), not bare " + "`python -m queue_backend.pg_queue.consumer`. Refusing to start " + "to avoid dropping every message as an unknown task." + ) self._running = True if install_signals: self._install_signal_handlers() + # Log the registered application tasks at startup. The guard above only + # catches an *empty* registry; a *wrong* one (e.g. the launcher selected + # the wrong source worker type) is non-empty but missing the target + # task, so each message would be dropped as "unknown task". Surfacing + # the registry here makes a wrong-type boot diagnosable from one line. + app_tasks = sorted( + name for name in self._app.tasks if not name.startswith("celery.") + ) logger.info( - "PG-queue consumer started (queue=%r, batch=%s, vt=%ss)", + "PG-queue consumer started (queue=%r, batch=%s, vt=%ss) — " + "%d application task(s) registered: %s", self.queue_name, self.batch_size, self.vt_seconds, + len(app_tasks), + ", ".join(app_tasks) or "(none)", ) backoff = self.poll_interval while self._running: @@ -226,8 +259,18 @@ def _install_signal_handlers(self) -> None: def main() -> None: logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) - def _env(suffix: str, default: object, cast: type) -> object: - return cast(os.getenv(f"WORKER_PG_QUEUE_CONSUMER_{suffix}", default)) + def _env(suffix: str, default: _T, cast: Callable[[str], _T]) -> _T: + # Preserve the default's type through to PgQueueConsumer's typed + # __init__ (a bare `type` would erase it). On a bad value, fail with the + # offending var name instead of a context-free `int('abc')` ValueError. + var = f"WORKER_PG_QUEUE_CONSUMER_{suffix}" + raw = os.getenv(var) + if raw is None: + return default + try: + return cast(raw) + except (ValueError, TypeError) as exc: + raise ValueError(f"Invalid {var}={raw!r}: {exc}") from exc PgQueueConsumer( queue_name=_env("QUEUE", _DEFAULT_QUEUE, str), diff --git a/workers/run-worker.sh b/workers/run-worker.sh index 6597907a1a..99751f007e 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -24,6 +24,9 @@ ENV_FILE="$WORKERS_DIR/.env" # Worker type constant for the executor worker readonly EXECUTOR_WORKER_TYPE="executor" readonly IDE_CALLBACK_WORKER_TYPE="ide_callback" +# Canonical name of the PG-queue consumer worker (referenced in several maps +# and special-cases below; a constant keeps them in sync). +readonly PG_QUEUE_CONSUMER_TYPE="pg_queue_consumer" # Available workers declare -A WORKERS=( @@ -44,6 +47,10 @@ declare -A WORKERS=( ["${EXECUTOR_WORKER_TYPE}"]="${EXECUTOR_WORKER_TYPE}" ["ide-callback"]="${IDE_CALLBACK_WORKER_TYPE}" ["${IDE_CALLBACK_WORKER_TYPE}"]="${IDE_CALLBACK_WORKER_TYPE}" + # PG Queue consumer — polls Postgres (SKIP LOCKED), not RabbitMQ via Celery + ["pg-queue-consumer"]="$PG_QUEUE_CONSUMER_TYPE" + ["$PG_QUEUE_CONSUMER_TYPE"]="$PG_QUEUE_CONSUMER_TYPE" + ["pg-consumer"]="$PG_QUEUE_CONSUMER_TYPE" ["all"]="all" ) @@ -61,6 +68,9 @@ declare -A WORKER_QUEUES=( ["scheduler"]="scheduler" ["${EXECUTOR_WORKER_TYPE}"]="celery_executor_legacy" ["${IDE_CALLBACK_WORKER_TYPE}"]="${IDE_CALLBACK_WORKER_TYPE}" + # 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" ) # Worker health ports @@ -74,6 +84,17 @@ declare -A WORKER_HEALTH_PORTS=( ["scheduler"]="8087" ["${EXECUTOR_WORKER_TYPE}"]="8088" ["${IDE_CALLBACK_WORKER_TYPE}"]="8089" + # pg_queue_consumer: no entry — it runs no health server, so it binds no + # port (avoids a hard-coded port that could collide). A liveness endpoint, + # if added later, should read WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT. +) + +# Opt-in workers: experimental and NOT part of the default "all" fleet, so +# they're started only on explicit request. Status shows them only when they +# are actually running, so a deliberate non-start isn't reported as a STOPPED +# failure (they'd otherwise show STOPPED after every `all`). +declare -A OPTIN_WORKERS=( + ["$PG_QUEUE_CONSUMER_TYPE"]=1 ) # Function to display usage @@ -93,9 +114,12 @@ WORKER_TYPE: scheduler, schedule Run scheduler worker (scheduled pipeline tasks) executor Run executor worker (extraction execution tasks) ide-callback Run IDE callback worker (Prompt Studio post-execution callbacks) + pg-queue-consumer Run PG-queue poll-loop consumer (opt-in; not part of 'all') all Run all workers (in separate processes, includes auto-discovered pluggable workers) Note: Pluggable workers in pluggable_worker/ directory are automatically discovered and can be run by name. +Note: pg-queue-consumer overrides: WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE (source worker whose + tasks to load, default notification) and WORKER_PG_QUEUE_CONSUMER_QUEUE (queue to poll). OPTIONS: -e, --env-file FILE Use specific environment file (default: .env) @@ -300,8 +324,26 @@ validate_env() { # --hostname=callback-worker@%h (default, no WORKER_INSTANCE_ID) # --hostname=callback-worker-${id}@%h (when WORKER_INSTANCE_ID is set) get_worker_pids() { - local worker_type=$1 - pgrep -f -- "[^[:alnum:]_]${worker_type}-worker(@|-)" || true + local worker_type=$1 pattern out rc + # The PG-queue consumer runs as `python -m pg_queue_consumer`, not a Celery + # `-worker@host` process, so it has no `-worker` token to anchor on. + # Match its module invocation instead (covers both the `uv run python` + # parent and the `python -m` child). Keeps --status / -k / -r working for it. + if [[ "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" ]]; then + pattern="-m[[:space:]]+${worker_type}([[:space:]]|\$)" + else + pattern="[^[:alnum:]_]${worker_type}-worker(@|-)" + fi + # pgrep exits 1 for "no match" (normal — absorbed) but >=2 for an + # operational/regex error. Distinguish them: collapsing rc>=2 to empty would + # make a live worker look absent (a -k no-op, or a duplicate spawn on -r). + out=$(pgrep -f -- "$pattern") + rc=$? + if (( rc > 1 )); then + print_status "$YELLOW" "warning: pgrep failed (rc=$rc) while matching $worker_type" >&2 + fi + [[ -n "$out" ]] && printf '%s\n' "$out" + return 0 } # Returns get_worker_pids output as a single space-separated string with @@ -475,6 +517,12 @@ show_status() { local pids pids=$(get_worker_pids_oneline "$worker") + # Opt-in workers aren't part of `all`; only surface them when running + # so an intentional non-start doesn't read as a STOPPED failure. + if [[ -z "$pids" && -n "${OPTIN_WORKERS[$worker]:-}" ]]; then + continue + fi + printf ' %-22s ' "$worker:" if [[ -n "$pids" ]]; then @@ -647,28 +695,66 @@ run_worker() { esac fi + # PG queue consumer is a plain Python poll-loop (polls Postgres via + # SKIP LOCKED, not a Celery/RabbitMQ worker) — override the celery command + # with the bootstrapping launcher and route the queue via env. + if [[ "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" ]]; then + export WORKER_PG_QUEUE_CONSUMER_QUEUE="$queues" + # The consumer registers ONE source worker's tasks (the launcher sets + # WORKER_TYPE from this before `import worker`). Default: notification — + # the worker that owns the first migrated leaf task, + # send_webhook_notification; override to drain another worker's queue. + export WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE="${WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE:-notification}" + cmd_args=("uv" "run" "python" "-m" "$PG_QUEUE_CONSUMER_TYPE") + fi + print_status $GREEN "Starting $worker_type worker..." print_status $BLUE "Directory: $worker_dir" print_status $BLUE "Worker Name: $worker_instance_name" print_status $BLUE "Queues: $queues" - print_status $BLUE "Health Port: ${WORKER_HEALTH_PORTS[$worker_type]}" + print_status $BLUE "Health Port: ${WORKER_HEALTH_PORTS[$worker_type]:-n/a}" print_status $BLUE "Command: ${cmd_args[*]}" # Change to appropriate directory # For pluggable workers, stay at workers root to allow module imports # For core workers, change to worker directory - if [[ -n "${PLUGGABLE_WORKERS[$worker_type]:-}" ]]; then + if [[ -n "${PLUGGABLE_WORKERS[$worker_type]:-}" || "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" ]]; then + # Run from the workers root so `python -m pg_queue_consumer` (and the + # `worker` app it bootstraps) resolve. cd "$WORKERS_DIR" else cd "$worker_dir" fi if [[ "$detach" == "true" ]]; then - # Run in background - nohup "${cmd_args[@]}" > "$worker_type.log" 2>&1 & + # Run in background. Write to an ABSOLUTE log path ($worker_dir is + # absolute) so the file lands where resolve_log_file() / -L / -C look, + # regardless of cwd. Workers that run from the workers root (pluggable + # workers, pg_queue_consumer) would otherwise drop a relative + # "$worker_type.log" at the root, where -L/-C can't find it. + local log_file="$worker_dir/$worker_type.log" + nohup "${cmd_args[@]}" > "$log_file" 2>&1 & local pid=$! + # set -e does not apply to backgrounded jobs, so a fork that dies on + # startup (e.g. the consumer's require_tasks RuntimeError, an + # `import worker` failure, a bad env cast) would still be reported as + # "started" — and for pg_queue_consumer, which has no health port, a + # dead process then just reads as absent in --status. Catch an + # *immediate* exit. This is a best-effort fast-fail for crash-on-import + # / bad-config faults (sub-second), NOT a connectivity check: a worker + # that dies slowly (e.g. a broker connect timing out after >1s) still + # passes here and surfaces later via its health port / --status. Run all + # detached workers share it — an immediate crash can hit any worker type; + # in `all` the subshells are backgrounded, so this 1s overlaps the + # inter-launch sleep rather than serializing. + sleep 1 + if ! kill -0 "$pid" 2>/dev/null; then + print_status $RED "$worker_type worker failed to start (PID $pid exited) — last log lines:" + tail -n 20 "$log_file" 2>/dev/null + return 1 + fi print_status $GREEN "$worker_type worker started in background (PID: $pid)" - print_status $BLUE "Logs: $worker_dir/$worker_type.log" + print_status $BLUE "Logs: $log_file" else # Run in foreground exec "${cmd_args[@]}" diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index 02e7d9c9cd..fa56e67833 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -208,6 +208,63 @@ def _sleep(secs): consumer.run(install_signals=False) # must not raise assert client.read.call_count == 2 # kept polling after the error + def test_run_refuses_to_start_with_empty_registry(self): + # A non-bootstrapped consumer would drop every message as "unknown" — + # fail loudly instead. + from celery import Celery + + empty_app = Celery("empty-no-tasks") # only celery.* built-ins + consumer = PgQueueConsumer("q", client=MagicMock(), app=empty_app) + with pytest.raises(RuntimeError, match="no application tasks"): + consumer.run(install_signals=False) + + def test_run_starts_with_registered_tasks(self, monkeypatch): + # Positive arm: a non-empty registry starts and polls. current_app has + # the module's @shared_task tasks registered, so the guard passes. + client = MagicMock() + client.read.return_value = [] # empty → loop sleeps → we stop it + consumer = PgQueueConsumer("q", client=client) + monkeypatch.setattr( + "queue_backend.pg_queue.consumer.time.sleep", lambda _s: consumer.stop() + ) + consumer.run(install_signals=False) # must not raise + assert client.read.called + + def test_run_bypasses_guard_when_require_tasks_false(self, monkeypatch): + # require_tasks=False skips the guard even on an empty registry (lets a + # caller opt out, e.g. a deliberately task-less smoke run). + from celery import Celery + + empty_app = Celery("empty-no-tasks") + client = MagicMock() + client.read.return_value = [] + consumer = PgQueueConsumer("q", client=client, app=empty_app) + monkeypatch.setattr( + "queue_backend.pg_queue.consumer.time.sleep", lambda _s: consumer.stop() + ) + consumer.run(install_signals=False, require_tasks=False) # must not raise + assert client.read.called + + def test_registered_task_count_excludes_celery_builtins(self): + # The guard counts application tasks only — celery.* built-ins (present + # in every app) must not mask an un-bootstrapped registry. + from celery import Celery + + empty_app = Celery("empty-no-tasks") + assert ( + PgQueueConsumer("q", client=MagicMock(), app=empty_app)._registered_task_count() + == 0 + ) + + @empty_app.task(name="test_pg_consumer.demo") + def _demo(): + return 1 + + assert ( + PgQueueConsumer("q", client=MagicMock(), app=empty_app)._registered_task_count() + == 1 + ) + # --- Integration: full enqueue → poll → execute → ack against real PG --- # Uses the shared ``pg_client`` fixture from conftest.py. From 9d7124feafcd62ee27cee2dde4dbdfecfe711cf3 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:03:11 +0530 Subject: [PATCH 07/65] UN-3544 [FEAT] PG-queue consumer liveness endpoint (poll-loop heartbeat) (#2051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3544 [FEAT] PG-queue consumer liveness endpoint (poll-loop heartbeat) Give the consumer a /health HTTP endpoint for K8s liveness probing, like every other worker — but keyed only on a poll-loop heartbeat. - consumer.py: track _last_poll_monotonic (refreshed at the top of poll_once, so a loop wedged on a long task goes stale and is detectable — which pgrep-based --status and the launch-liveness check cannot see). Expose seconds_since_last_poll() / is_poll_stale(). main() starts a LivenessServer when WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT is set (opt-in), stops it on exit. - LivenessServer: tiny stdlib HTTP server (/health, /healthz, /livez) → 200 while fresh, 503 once stale. Deliberately lean: a liveness probe must report only "is this process making progress?", NOT broker/API reachability or resource pressure (those would crash-loop a healthy consumer on a blip). So it does NOT reuse the shared HealthChecker (which also bundles an api_connectivity check that is both wrong for liveness and currently broken — its `from .api_client_singleton` import points at a non-existent module; tracked separately). - run-worker.sh: default port 8090 (outside the 8080-8089 core range, no collision), exported opt-in; documented in --help. - tests: heartbeat fresh/stale + a real bind-and-GET 200->503 endpoint test. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3544 [FIX] Address PR #2051 review feedback Important: - Liveness bind failure (OSError/port-in-use) now degrades gracefully: log and continue probe-less instead of aborting the consumer before it polls. Verified live (2nd consumer on a taken :8090 keeps draining). - run-worker.sh: 8090 collided with the first auto-discovered pluggable worker (8090 + count); pluggable discovery now starts at 8091, 8090 reserved. - LivenessServer.stop() is defensive (can't raise into main()'s finally and mask the real run() exception) and warns if the thread outlives the join. - Empty WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT now hits the clean opt-out (_env treats "" as unset) instead of int("") crashing at launch. Suggestions: - LivenessServer: guard double start(); reset state in stop(); wrap serve_forever so a thread crash is logged; route handler errors to the logger (log_message=pass was hiding log_error too); guard wfile.write against client disconnects; single clock read per request. - Type _httpd/_thread as HTTPServer|None / Thread|None via TYPE_CHECKING (drops the Any import); restores static checking. - run-worker.sh: status line shows the effective -p override, not the map default. - Docstrings: phrase the helper trigger in terms of `port`; note 0.0.0.0 bind; document that a fast-failing loop stays healthy by design (liveness must not couple to backend reachability). - tests: heartbeat-stamped-before-read (pins top-of-poll), /healthz + /livez aliases + unknown-path 404, double-start rejection. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3544 [FIX] Address SonarCloud issues - consumer.py: use logger.exception() in the liveness-bind except block (preserves the traceback; S6679). - test: lift the walrus assignment out of the PgQueueConsumer() argument list — plain `client = MagicMock()` first (clearer; S6328). Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3544 [FIX] Address Greptile review feedback - LivenessServer handler strips the query string before matching paths (self.path includes it), so /health?probe=k8s returns 200 not 404. Added a query-string case to the alias test. - Document the stale-threshold trade-off prominently: the heartbeat is frozen during task execution, so WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS is also an upper bound on single-task wall-clock (a longer task trips the probe → restart → redelivery). 60s suits the sub-second leaf; raise it above max(batch_size x worst_case_task_seconds, backoff_max) for longer tasks. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- workers/queue_backend/pg_queue/consumer.py | 215 ++++++++++++++++++++- workers/run-worker.sh | 24 ++- workers/tests/test_pg_queue_consumer.py | 113 +++++++++++ 3 files changed, 342 insertions(+), 10 deletions(-) diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index d7f527f705..856a038a26 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -33,6 +33,9 @@ from .client import PgQueueClient if TYPE_CHECKING: + from http.server import HTTPServer + from threading import Thread + from celery import Celery from .client import QueueMessage @@ -53,6 +56,15 @@ # A task claimed more than this many times keeps failing — drop it (poison) # rather than redeliver forever. _DEFAULT_MAX_ATTEMPTS = 5 +# Liveness: a poll loop that hasn't cycled in this many seconds is reported +# unhealthy. The heartbeat is stamped at the top of each poll_once and frozen +# during task execution, so this threshold doubles as an UPPER BOUND on a single +# task's wall-clock: a task running longer than it trips the probe → pod restart +# → the in-flight task is killed and (at-least-once) redelivered. 60s suits the +# current sub-second leaf (send_webhook_notification); for longer-running tasks, +# raise WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS above +# max(batch_size x worst_case_task_seconds, backoff_max). +_DEFAULT_HEALTH_STALE_SECONDS = 60.0 class PgQueueConsumer: @@ -97,9 +109,16 @@ def __init__( self.backoff_max = backoff_max self.max_attempts = max_attempts self._running = False + # Heartbeat for the liveness probe: monotonic timestamp of the most + # recent poll attempt. Seeded at construction so a just-started consumer + # reads healthy. Updated at the TOP of poll_once, so a loop wedged on a + # long-running task (poll_once not returning) goes stale and is caught — + # something pgrep-based --status and the launch-time check cannot see. + self._last_poll_monotonic = time.monotonic() def poll_once(self) -> int: """Claim + process one batch; returns the number of messages claimed.""" + self._last_poll_monotonic = time.monotonic() messages = self._client.read( self.queue_name, vt_seconds=self.vt_seconds, qty=self.batch_size ) @@ -185,6 +204,30 @@ def _registered_task_count(self) -> int: """Count application tasks (excluding Celery's built-ins).""" return sum(1 for name in self._app.tasks if not name.startswith("celery.")) + def seconds_since_last_poll(self) -> float: + """Seconds since the last poll attempt (for the liveness heartbeat).""" + return time.monotonic() - self._last_poll_monotonic + + def is_poll_stale(self, stale_after_seconds: float) -> bool: + """True if the poll loop hasn't cycled within ``stale_after_seconds``. + + Drives the health endpoint: a stale loop means the consumer is wedged + (deadlock, or a single task running longer than the threshold), so the + liveness probe should report unhealthy and let the orchestrator restart + it. Pick a threshold comfortably above ``backoff_max`` and the longest + expected task so normal idle/backoff never trips it. + + Note the heartbeat is stamped at the *top* of ``poll_once`` (before the + DB read), so a loop that fails fast every cycle — e.g. ``read()`` raising + on an unreachable DB, caught and backed off by ``run()`` — keeps stamping + and stays *healthy*. That is deliberate: a liveness probe must not couple + to backend reachability (a restart can't fix a DB outage, and coupling + would crash-loop every consumer during one). Surfacing a permanent + config fault (bad creds, missing schema) is a readiness/alerting concern, + not liveness. + """ + return self.seconds_since_last_poll() > stale_after_seconds + def run(self, *, install_signals: bool = True, require_tasks: bool = True) -> None: """Poll loop with empty-queue backoff and graceful shutdown. @@ -265,21 +308,187 @@ def _env(suffix: str, default: _T, cast: Callable[[str], _T]) -> _T: # offending var name instead of a context-free `int('abc')` ValueError. var = f"WORKER_PG_QUEUE_CONSUMER_{suffix}" raw = os.getenv(var) - if raw is None: + # Treat empty-string as unset: an empty HEALTH_PORT (e.g. a run-worker.sh + # fallback resolving empty) must hit the clean opt-out, not int("") crash. + if raw is None or raw == "": return default try: return cast(raw) except (ValueError, TypeError) as exc: raise ValueError(f"Invalid {var}={raw!r}: {exc}") from exc - PgQueueConsumer( + consumer = PgQueueConsumer( queue_name=_env("QUEUE", _DEFAULT_QUEUE, str), batch_size=_env("BATCH", _DEFAULT_BATCH, int), vt_seconds=_env("VT_SECONDS", _DEFAULT_VT_SECONDS, int), poll_interval=_env("POLL_INTERVAL", _DEFAULT_POLL_INTERVAL, float), backoff_max=_env("BACKOFF_MAX", _DEFAULT_BACKOFF_MAX, float), max_attempts=_env("MAX_ATTEMPTS", _DEFAULT_MAX_ATTEMPTS, int), - ).run() + ) + health_server = _maybe_start_health_server( + consumer, + port=_env("HEALTH_PORT", None, int), + stale_after=_env("HEALTH_STALE_SECONDS", _DEFAULT_HEALTH_STALE_SECONDS, float), + ) + try: + consumer.run() + finally: + if health_server is not None: + health_server.stop() + + +def _maybe_start_health_server( + consumer: PgQueueConsumer, *, port: int | None, stale_after: float +) -> LivenessServer | None: + """Start the liveness server when ``port`` is not None; else ``None``. + + ``main()`` wires ``port`` from ``WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT`` (unset + → ``None`` → no server, no stray port). A bind failure degrades gracefully: + the probe is auxiliary, so it must never stop the consumer from draining the + queue — we log and continue probe-less rather than abort startup. + """ + if port is None: + logger.info( + "PG-queue consumer: WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT unset — " + "liveness server disabled" + ) + return None + server = LivenessServer(consumer, port=port, stale_after=stale_after) + try: + server.start() + except OSError: + logger.exception( + "PG-queue consumer: liveness server could not bind :%s — " + "continuing WITHOUT a probe", + port, + ) + return None + logger.info( + "PG-queue consumer: liveness server on :%s/health (stale after %ss)", + server.bound_port, + stale_after, + ) + return server + + +class LivenessServer: + """Tiny HTTP liveness probe: 200 while the poll loop is fresh, else 503. + + Deliberately lean. A *liveness* probe must answer one question — "is this + process still making progress?" — and nothing else. It must NOT depend on + broker/API reachability or resource pressure: a transient backend blip or a + busy moment would otherwise make the orchestrator crash-loop an + otherwise-healthy consumer. So this intentionally does *not* reuse the + shared ``HealthChecker`` (which bundles api-connectivity / system-resource + checks meant for richer health reporting, not liveness) — it reports solely + on the poll-loop heartbeat (:meth:`PgQueueConsumer.is_poll_stale`). + + Serves ``/health`` (also ``/healthz``, ``/livez``) on ``0.0.0.0`` (all + interfaces — a container/k8s probe reaches it from outside the process) in a + daemon thread. Bind ``port=0`` to let the OS pick a free port (read back via + :attr:`bound_port`) — used in tests. Start once; :meth:`stop` returns it to + the inert state. + """ + + _PATHS = frozenset({"/health", "/healthz", "/livez"}) + + def __init__( + self, consumer: PgQueueConsumer, *, port: int, stale_after: float + ) -> None: + self._consumer = consumer + self._port = port + self._stale_after = stale_after + self._httpd: HTTPServer | None = None + self._thread: Thread | None = None + + def start(self) -> None: + import json + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + from urllib.parse import urlsplit + + if self._httpd is not None: + raise RuntimeError("LivenessServer already started") + + consumer = self._consumer + stale_after = self._stale_after + paths = self._PATHS + + class _Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + # Strip any query string — self.path includes it, so a probe + # like /health?foo=bar must still match. + if urlsplit(self.path).path not in paths: + self.send_response(404) + self.end_headers() + return + # One clock read so age and the healthy/stale verdict are + # derived from the same instant. + age = consumer.seconds_since_last_poll() + stale = age > stale_after + body = json.dumps( + { + "status": "unhealthy" if stale else "healthy", + "check": "pg_queue_poll", + "seconds_since_last_poll": round(age, 3), + "stale_after_seconds": stale_after, + } + ).encode() + self.send_response(503 if stale else 200) + self.send_header("Content-Type", "application/json") + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass # client (probe) hung up mid-response — not our problem + + def log_message(self, *_: object) -> None: + pass # silence per-request access logging + + def log_error(self, fmt: str, *args: object) -> None: + # BaseHTTPRequestHandler routes errors through log_message too; + # don't let the pass above swallow them — surface to our logger. + logger.warning("pg-queue liveness handler: " + fmt, *args) + + def _serve(httpd: HTTPServer) -> None: + try: + httpd.serve_forever() + except Exception: + # A daemon thread dying silently would make /health stop + # answering (connection refused) with no breadcrumb. + logger.exception("pg-queue liveness server thread crashed") + + httpd = HTTPServer(("0.0.0.0", self._port), _Handler) + self._httpd = httpd + self._thread = threading.Thread( + target=_serve, args=(httpd,), daemon=True, name="pg-consumer-liveness" + ) + self._thread.start() + + @property + def bound_port(self) -> int: + """Actual listening port (resolves ``port=0``); the requested port if not started.""" + if self._httpd is not None: + return self._httpd.server_address[1] + return self._port + + def stop(self) -> None: + """Shut the server down. Defensive: never raises (called from a finally).""" + try: + if self._httpd is not None: + self._httpd.shutdown() + self._httpd.server_close() + if self._thread is not None: + self._thread.join(timeout=5) + if self._thread.is_alive(): + logger.warning( + "PG-queue consumer: liveness thread did not stop within 5s" + ) + except Exception: + logger.exception("PG-queue consumer: error stopping liveness server") + finally: + self._httpd = None + self._thread = None if __name__ == "__main__": diff --git a/workers/run-worker.sh b/workers/run-worker.sh index 99751f007e..01875712e3 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -84,9 +84,12 @@ declare -A WORKER_HEALTH_PORTS=( ["scheduler"]="8087" ["${EXECUTOR_WORKER_TYPE}"]="8088" ["${IDE_CALLBACK_WORKER_TYPE}"]="8089" - # pg_queue_consumer: no entry — it runs no health server, so it binds no - # port (avoids a hard-coded port that could collide). A liveness endpoint, - # if added later, should read WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT. + # pg_queue_consumer: 8090 — reserved here, just past the 8080-8089 core + # range and just below where pluggable-worker discovery starts allocating + # (8091+, see below), so it collides with neither. The consumer binds it + # only when WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT is exported (below); a bare + # `python -m pg_queue_consumer` binds nothing. + ["$PG_QUEUE_CONSUMER_TYPE"]="8090" ) # Opt-in workers: experimental and NOT part of the default "all" fleet, so @@ -119,7 +122,8 @@ WORKER_TYPE: Note: Pluggable workers in pluggable_worker/ directory are automatically discovered and can be run by name. Note: pg-queue-consumer overrides: WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE (source worker whose - tasks to load, default notification) and WORKER_PG_QUEUE_CONSUMER_QUEUE (queue to poll). + tasks to load, default notification), WORKER_PG_QUEUE_CONSUMER_QUEUE (queue to poll), + and WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT (liveness server port, default 8090). OPTIONS: -e, --env-file FILE Use specific environment file (default: .env) @@ -272,9 +276,11 @@ discover_pluggable_workers() { WORKER_QUEUES["$worker_name"]="$worker_name" fi - # Assign health port dynamically (starting from 8090) + # Assign health port dynamically (starting from 8091; 8090 is + # reserved for pg_queue_consumer, so the first pluggable worker + # doesn't collide with it). if [[ -z "${WORKER_HEALTH_PORTS[$worker_name]:-}" ]]; then - WORKER_HEALTH_PORTS["$worker_name"]=$((8090 + discovered_count)) + WORKER_HEALTH_PORTS["$worker_name"]=$((8091 + discovered_count)) fi print_status $GREEN "Discovered pluggable worker: $worker_name" @@ -705,6 +711,9 @@ run_worker() { # the worker that owns the first migrated leaf task, # send_webhook_notification; override to drain another worker's queue. export WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE="${WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE:-notification}" + # Liveness HTTP server port (-p override wins, else the map default). + # Exported so the launcher's main() opts into the health server. + export WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT="${health_port:-${WORKER_HEALTH_PORTS[$worker_type]}}" cmd_args=("uv" "run" "python" "-m" "$PG_QUEUE_CONSUMER_TYPE") fi @@ -712,7 +721,8 @@ run_worker() { print_status $BLUE "Directory: $worker_dir" print_status $BLUE "Worker Name: $worker_instance_name" print_status $BLUE "Queues: $queues" - print_status $BLUE "Health Port: ${WORKER_HEALTH_PORTS[$worker_type]:-n/a}" + # Show the effective port: a -p/--health-port override wins over the map. + print_status $BLUE "Health Port: ${health_port:-${WORKER_HEALTH_PORTS[$worker_type]:-n/a}}" print_status $BLUE "Command: ${cmd_args[*]}" # Change to appropriate directory diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index fa56e67833..372252770a 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -266,6 +266,119 @@ def _demo(): ) +# --- Liveness heartbeat (drives the health endpoint) --- + + +class TestPollHeartbeat: + def test_poll_once_refreshes_heartbeat(self): + client = MagicMock() + client.read.return_value = [] + consumer = PgQueueConsumer("q", client=client) + # Simulate a long-idle consumer, then poll → heartbeat resets to ~now. + consumer._last_poll_monotonic -= 120 + assert consumer.seconds_since_last_poll() > 100 + consumer.poll_once() + assert consumer.seconds_since_last_poll() < 1.0 + + def test_heartbeat_stamped_before_read(self): + # Pins the headline design: the stamp lands at the TOP of poll_once + # (before read), so a task running longer than the threshold still trips + # the probe. A bottom-of-poll stamp would pass test_poll_once_refreshes + # but fail here. + client = MagicMock() + consumer = PgQueueConsumer("q", client=client) + before = consumer._last_poll_monotonic + seen: dict[str, float] = {} + client.read.side_effect = lambda *a, **k: ( + seen.setdefault("during", consumer._last_poll_monotonic), + [], + )[1] + consumer.poll_once() + assert seen["during"] > before # refreshed BEFORE read ran, not after + + def test_is_poll_stale_threshold(self): + consumer = PgQueueConsumer("q", client=MagicMock()) + consumer._last_poll_monotonic -= 120 # last poll 120s ago + assert consumer.is_poll_stale(60) is True # past threshold → stale + assert consumer.is_poll_stale(200) is False # within threshold → fresh + + def test_fresh_consumer_is_not_stale(self): + # Seeded at construction, so a just-started consumer reads healthy. + consumer = PgQueueConsumer("q", client=MagicMock()) + assert consumer.is_poll_stale(60) is False + + def test_health_server_disabled_without_port(self): + # No port configured → no server bound (opt-in). + from queue_backend.pg_queue.consumer import _maybe_start_health_server + + consumer = PgQueueConsumer("q", client=MagicMock()) + assert _maybe_start_health_server(consumer, port=None, stale_after=60) is None + + def test_liveness_server_reports_200_then_503(self): + # Real endpoint: 200 while the poll loop is fresh, 503 once it goes + # stale. Bind port 0 so the OS picks a free port (no fixed-port clash). + import json + import urllib.error + import urllib.request + + from queue_backend.pg_queue.consumer import LivenessServer + + consumer = PgQueueConsumer("q", client=MagicMock()) + server = LivenessServer(consumer, port=0, stale_after=60) + server.start() + try: + url = f"http://127.0.0.1:{server.bound_port}/health" + with urllib.request.urlopen(url, timeout=5) as resp: + assert resp.status == 200 + assert json.loads(resp.read())["status"] == "healthy" + + consumer._last_poll_monotonic -= 120 # force the loop stale + with pytest.raises(urllib.error.HTTPError) as ei: + urllib.request.urlopen(url, timeout=5) + assert ei.value.code == 503 + assert json.loads(ei.value.read())["status"] == "unhealthy" + finally: + server.stop() + + def test_liveness_aliases_and_unknown_path(self): + # All three probe aliases answer 200 (different orchestrators probe + # different paths); an unknown path is 404 (guards against a regression + # that makes every path pass). + import urllib.error + import urllib.request + + from queue_backend.pg_queue.consumer import LivenessServer + + consumer = PgQueueConsumer("q", client=MagicMock()) + server = LivenessServer(consumer, port=0, stale_after=60) + server.start() + try: + base = f"http://127.0.0.1:{server.bound_port}" + # Aliases, plus a query string (self.path includes it) must match. + for path in ("/health", "/healthz", "/livez", "/health?probe=k8s"): + with urllib.request.urlopen(f"{base}{path}", timeout=5) as resp: + assert resp.status == 200, path + with pytest.raises(urllib.error.HTTPError) as ei: + urllib.request.urlopen(f"{base}/nope", timeout=5) + assert ei.value.code == 404 + finally: + server.stop() + + def test_double_start_is_rejected(self): + from queue_backend.pg_queue.consumer import LivenessServer + + server = LivenessServer(PgQueueConsumer("q", client=MagicMock()), port=0, stale_after=60) + server.start() + try: + with pytest.raises(RuntimeError, match="already started"): + server.start() + finally: + server.stop() + # stop() returns it to the inert state → can start again. + server.start() + server.stop() + + # --- Integration: full enqueue → poll → execute → ack against real PG --- # Uses the shared ``pg_client`` fixture from conftest.py. From 0955df981f87b7b2978ccaa03148a7e99787d162 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:08:39 +0530 Subject: [PATCH 08/65] UN-3546 [FEAT] Priority-ordered PG-queue dequeue + concurrency-safe claim (#2052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3546 [FEAT] Priority-ordered PG-queue dequeue + concurrency-safe claim Start enforcing the load-independent part of fairness — pipeline_priority (L3) — directly in the single-table dequeue: higher priority is claimed first, FIFO (msg_id) within a priority. The org-tier (L1) / workload (L2) axes + burst_max admission stay deferred to the fair-admission orchestrator. - Schema: add `priority` (smallint, default 5 = FairnessKey.DEFAULT_PRIORITY) to pg_queue_message; swap the dequeue index to (queue_name, priority DESC, msg_id) so the priority-ordered claim stays an indexed top-N. - Enqueue: dispatch() writes priority from fairness.pipeline_priority; a bare dispatch (fairness=None) writes the neutral default. - Dequeue: ORDER BY priority DESC, msg_id. Also fixes a latent concurrency bug in the original dequeue (9a): `UPDATE ... WHERE msg_id IN (SELECT ... FOR UPDATE SKIP LOCKED LIMIT n)` can OVER-CLAIM under concurrent writers — EvalPlanQual re-evaluates the LIMIT subquery when a row it tried to lock was concurrently touched, so one claim can return more than n rows. Switched to the canonical PGMQ-safe shape: lock candidates in a CTE, then `UPDATE ... FROM locked WHERE q.msg_id = locked.msg_id`, which locks exactly n rows once. The trailing SELECT re-orders RETURNING (which is otherwise unspecified) so batched claims come back in priority order too. Tests: priority selection (one-at-a-time) + batch ordering against real Postgres; send writes priority; dispatch wiring (fairness + neutral default); read param order. Verified live end-to-end via dispatch()->claim-order (9>7>3a>3b>1). Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3546 [FIX] Address PR #2052 review feedback - Validate priority at the write boundary: client.send() raises ValueError on out-of-range (mirrors its vt_seconds/qty guards) — an out-of-range value would silently jump/sink the row in the priority DESC claim order. - Add a DB CheckConstraint (priority 1..10) as the backstop no ORM/raw writer can bypass (migration 0003). check= (not condition=) — repo is on Django 4.2. - Soften the "indexed top-N" comments (client.py + models.py): the dequeue is an index walk with vt<=now() as a per-row filter, NOT a guaranteed top-N — vt is not in the index, so in-flight (future-vt) high-priority rows are scanned past on each claim; the orchestrator's admission is the high-backlog answer. Update the module docstring to the CTE FROM-join shape; fix the fairness.DEFAULT_PRIORITY symbol reference; drop the duplicated param-order comment. - Tests: send() range-guard (parametrized) + DB CheckConstraint backstop; concurrent-writer over-claim guard (two readers, no batch exceeds qty — the regression test for the EvalPlanQual fix); vt × priority (visible low beats invisible high); FIFO-within-band for multi-member batch bands. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3546 [FIX] Address Greptile review feedback - Concurrency test: assert the drain worker terminated after join (a hung worker now fails the test instead of passing silently while conn_b.close() races its in-flight queries). - Priority-bounds drift guard: backend models.py and workers fairness.py are separate codebases that can't import each other, so the DB constraint bounds (1/10) duplicate fairness.MIN/MAX_PRIORITY. Replaced the hardcoded "42" reject test with test_db_check_constraint_matches_fairness_bounds — raw-inserts at MIN/MAX (accepted) and MIN-1/MAX+1 (CheckViolation), pinning the DB constraint to the fairness range so a future widening that misses one side fails loudly. Documented the canonical source in the model comment. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3546 [DOCS] Note check=/condition= handling on the constraint migration Breadcrumb for a future Django upgrade: `check=` is correct on the pinned Django 4.2 (deprecated 5.1, removed 6.0); fresh installs always replay under the shipped Django, so leave it. When the pin reaches >= 6.0, squash (or do the behaviour-preserving check= -> condition= edit) so a from-scratch migrate runs. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...e_pg_queue_message_dequeue_idx_and_more.py | 30 ++++ ...message_pg_queue_message_priority_range.py | 25 ++++ backend/pg_queue/models.py | 30 +++- workers/queue_backend/dispatch.py | 8 +- workers/queue_backend/pg_queue/client.py | 108 ++++++++++---- workers/tests/test_dispatch_pg.py | 35 +++++ workers/tests/test_pg_queue_client.py | 137 +++++++++++++++++- 7 files changed, 339 insertions(+), 34 deletions(-) create mode 100644 backend/pg_queue/migrations/0002_remove_pgqueuemessage_pg_queue_message_dequeue_idx_and_more.py create mode 100644 backend/pg_queue/migrations/0003_pgqueuemessage_pg_queue_message_priority_range.py diff --git a/backend/pg_queue/migrations/0002_remove_pgqueuemessage_pg_queue_message_dequeue_idx_and_more.py b/backend/pg_queue/migrations/0002_remove_pgqueuemessage_pg_queue_message_dequeue_idx_and_more.py new file mode 100644 index 0000000000..08cd58106e --- /dev/null +++ b/backend/pg_queue/migrations/0002_remove_pgqueuemessage_pg_queue_message_dequeue_idx_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.1 on 2026-06-12 11:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0001_initial"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="pgqueuemessage", + name="pg_queue_message_dequeue_idx", + ), + migrations.AddField( + model_name="pgqueuemessage", + name="priority", + field=models.SmallIntegerField(default=5), + ), + migrations.AddIndex( + model_name="pgqueuemessage", + index=models.Index( + models.F("queue_name"), + models.OrderBy(models.F("priority"), descending=True), + models.F("msg_id"), + name="pg_queue_message_dequeue_idx", + ), + ), + ] diff --git a/backend/pg_queue/migrations/0003_pgqueuemessage_pg_queue_message_priority_range.py b/backend/pg_queue/migrations/0003_pgqueuemessage_pg_queue_message_priority_range.py new file mode 100644 index 0000000000..68e141c8fc --- /dev/null +++ b/backend/pg_queue/migrations/0003_pgqueuemessage_pg_queue_message_priority_range.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.1 on 2026-06-12 14:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0002_remove_pgqueuemessage_pg_queue_message_dequeue_idx_and_more"), + ] + + operations = [ + # `check=` is correct for the pinned Django (4.2). It's deprecated in + # 5.1 in favour of `condition=` and removed in 6.0 — but fresh installs + # always replay this under the Django we ship, so leave it as-is. + # When the pin is bumped to >= 6.0, squash these migrations (or do the + # behaviour-preserving `check=` -> `condition=` edit) as part of that + # upgrade so a from-scratch migrate still runs. + migrations.AddConstraint( + model_name="pgqueuemessage", + constraint=models.CheckConstraint( + check=models.Q(("priority__gte", 1), ("priority__lte", 10)), + name="pg_queue_message_priority_range", + ), + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index 771fd909e6..8b3386cb27 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models import F from django.utils import timezone @@ -29,12 +30,39 @@ class PgQueueMessage(models.Model): enqueued_at = models.DateTimeField(default=timezone.now) vt = models.DateTimeField(default=timezone.now) # visibility timeout read_ct = models.IntegerField(default=0) + # fairness L3: higher value is claimed sooner. The canonical range lives in + # workers/queue_backend/fairness.py (MIN_PRIORITY=1, MAX_PRIORITY=10, + # DEFAULT_PRIORITY=5); these literals mirror it because backend and workers + # are separate codebases that can't import each other. Keep them in sync — a + # workers integration test (test_db_check_constraint_matches_fairness_bounds) + # asserts this DB constraint matches the fairness bounds, so a divergence + # fails loudly. The dispatch writes priority from fairness.pipeline_priority; + # leaf tasks with no fairness get the neutral default. L1 (org tier) / L2 + # (workload) ordering + burst_max admission are deferred to the fair-admission + # orchestrator. The CheckConstraint is the one backstop no writer can bypass. + priority = models.SmallIntegerField(default=5) class Meta: db_table = "pg_queue_message" + constraints = [ + models.CheckConstraint( + check=models.Q(priority__gte=1) & models.Q(priority__lte=10), + name="pg_queue_message_priority_range", + ), + ] indexes = [ + # The dequeue walks one queue in (priority DESC, msg_id ASC) order + # and applies vt <= now() as a per-row filter during the walk — + # claim high-priority first, FIFO (msg_id, monotonic and stable + # across re-claims) within a band. Note this is NOT a guaranteed + # top-N: vt is intentionally not in the index, so claimed-but-unacked + # rows (future vt) sit at the front of their band and are scanned + # past on each claim. Fine at low in-flight depth; the orchestrator's + # staging→task admission is the answer if that backlog grows large. models.Index( - fields=["queue_name", "vt", "msg_id"], + F("queue_name"), + F("priority").desc(), + F("msg_id"), name="pg_queue_message_dequeue_idx", ) ] diff --git a/workers/queue_backend/dispatch.py b/workers/queue_backend/dispatch.py index 68261293df..7012b0a7e4 100644 --- a/workers/queue_backend/dispatch.py +++ b/workers/queue_backend/dispatch.py @@ -27,7 +27,7 @@ from celery import current_app -from .fairness import FairnessKey +from .fairness import DEFAULT_PRIORITY, FairnessKey from .handle import DispatchHandle from .pg_queue import PgQueueClient, to_payload from .routing import QueueBackend, select_backend @@ -126,10 +126,16 @@ def _enqueue_pg( task_name, args=args, kwargs=kwargs, queue=queue, fairness=fairness ) try: + # Carry org_id + L3 priority onto the row so the dequeue can order by + # priority. A bare dispatch (fairness=None) writes the neutral defaults + # (org_id None → "" / DEFAULT_PRIORITY). msg_id = _get_pg_client().send( pg_queue, payload, org_id=fairness.org_id if fairness is not None else None, + priority=( + fairness.pipeline_priority if fairness is not None else DEFAULT_PRIORITY + ), ) except Exception: # Re-raise with a breadcrumb (raw psycopg2.Error / a json.dumps diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py index 030f8d8921..8024f11ebc 100644 --- a/workers/queue_backend/pg_queue/client.py +++ b/workers/queue_backend/pg_queue/client.py @@ -1,13 +1,13 @@ """Thin client over the bespoke PG queue (extension-free, ``SKIP LOCKED``). -**Inert in this phase** — nothing in ``dispatch()`` calls this yet (the -routing gate's PG branch still routes to Celery). This is the storage + -dequeue primitive that the enqueue wiring (9b) and the consumer poll -loop (9c) build on. +This is the storage + dequeue primitive the enqueue wiring (9b) and the +consumer poll loop (9c) build on; ``dispatch()`` routes PG-opted tasks here. Dequeue uses the visibility-timeout pattern: :meth:`PgQueueClient.read` -runs a single atomic ``UPDATE … WHERE msg_id IN (SELECT … FOR UPDATE -SKIP LOCKED …) RETURNING …`` (committed immediately), the caller +runs a single atomic statement — candidate rows are locked in a CTE +(``SELECT … FOR UPDATE SKIP LOCKED LIMIT n``) and that CTE is joined into +the ``UPDATE … FROM locked`` (the EvalPlanQual-safe shape; see +``_DEQUEUE_SQL``) — committed immediately, the caller processes the message *outside* the transaction, then :meth:`PgQueueClient.delete` acks on success. A crash before delete leaves the row to reappear once its ``vt`` expires — **at-least-once** @@ -35,6 +35,7 @@ import psycopg2 +from ..fairness import DEFAULT_PRIORITY, MAX_PRIORITY, MIN_PRIORITY from .connection import create_pg_connection if TYPE_CHECKING: @@ -50,25 +51,53 @@ # (at-least-once — a message CAN be processed more than once). No lock held # during processing -> VACUUM-safe and PgBouncer txn-pooling compatible. # -# ORDER BY (vt, msg_id) — not just msg_id — so the (queue_name, vt, msg_id) +# ORDER BY (priority DESC, msg_id) — the (queue_name, priority DESC, msg_id) # index drives an indexed top-N: for a fixed queue the index is ordered by -# (vt, msg_id), so the vt<=now() range scan emits rows already sorted and PG -# applies LIMIT without sorting the whole visible backlog. Still effectively -# FIFO (vt is set to now() at enqueue; msg_id is the deterministic tiebreak). +# (priority DESC, msg_id), so PG walks rows highest-priority-first, applies the +# vt<=now() visibility filter as it goes, and stops at LIMIT — no sort of the +# whole visible backlog. Higher priority is claimed sooner; msg_id ASC is the +# FIFO tiebreak within a priority (monotonic, and unlike vt it never moves when +# a row is re-claimed). Fairness L1 (org tier) / L2 (workload) + burst_max +# admission are deferred to the fair-admission orchestrator (a later phase). +# The inner ORDER BY selects WHICH rows are claimed (the top-N by priority when +# LIMIT < available). The outer SELECT re-applies it because UPDATE ... RETURNING +# yields rows in an unspecified order — so for batch_size > 1 the caller still +# gets the batch in priority order, not physical/update order. (At the default +# batch_size = 1 only the single highest-priority row is claimed, so the outer +# sort is a no-op there.) +# Canonical PGMQ-safe shape: lock the candidate rows in a CTE, then UPDATE by +# JOINING that CTE (``UPDATE ... FROM locked WHERE msg_id = locked.msg_id``). +# The alternative — ``UPDATE ... WHERE msg_id IN (SELECT ... FOR UPDATE SKIP +# LOCKED LIMIT n)`` — can over-claim under concurrent writers: EvalPlanQual +# re-evaluates the LIMIT subquery when a row it tried to lock was concurrently +# touched, so a single claim can return more than ``n`` rows. The FROM-join form +# locks exactly ``n`` rows once and updates precisely those. The trailing SELECT +# re-applies the order because UPDATE ... RETURNING is otherwise unordered. +# +# Ordering is an index walk over (queue_name, priority DESC, msg_id) with +# ``vt <= now()`` applied as a per-row filter — not a guaranteed top-N: vt is +# not in the index, so claimed-but-unacked rows (future vt) at the front of a +# priority band are scanned past on each claim. Cheap at low in-flight depth. _DEQUEUE_SQL = """ -UPDATE pg_queue_message - SET vt = now() + make_interval(secs => %s), - read_ct = read_ct + 1 - WHERE msg_id IN ( - SELECT msg_id - FROM pg_queue_message - WHERE queue_name = %s - AND vt <= now() - ORDER BY vt, msg_id - FOR UPDATE SKIP LOCKED - LIMIT %s - ) -RETURNING msg_id, message, read_ct +WITH locked AS ( + SELECT msg_id + FROM pg_queue_message + WHERE queue_name = %s + AND vt <= now() + ORDER BY priority DESC, msg_id + FOR UPDATE SKIP LOCKED + LIMIT %s +), claimed AS ( + UPDATE pg_queue_message q + SET vt = now() + make_interval(secs => %s), + read_ct = read_ct + 1 + FROM locked + WHERE q.msg_id = locked.msg_id + RETURNING q.msg_id, q.message, q.read_ct, q.priority +) +SELECT msg_id, message, read_ct + FROM claimed + ORDER BY priority DESC, msg_id """ @@ -144,22 +173,43 @@ def _cursor(self) -> Iterator[Any]: raise def send( - self, queue_name: str, message: dict[str, Any], *, org_id: str | None = None + self, + queue_name: str, + message: dict[str, Any], + *, + org_id: str | None = None, + priority: int = DEFAULT_PRIORITY, ) -> int: """Enqueue a message; returns its ``msg_id``. Immediately visible — ``vt`` is set to ``now()`` (DB clock). The timestamp/counter columns are supplied here rather than via DB defaults so the schema stays a plain Django migration. + + ``priority`` (fairness L3) controls dequeue order — higher is claimed + sooner. Defaults to the neutral ``DEFAULT_PRIORITY`` for tasks dispatched + without a fairness key (leaf tasks). Must be in ``[MIN_PRIORITY, + MAX_PRIORITY]`` — out of range raises (it would silently jump/sink the + row in the ``priority DESC`` claim order), mirroring ``read()``'s guards. + The DB ``CheckConstraint`` is the backstop for any ORM/raw writer. """ + if not MIN_PRIORITY <= priority <= MAX_PRIORITY: + raise ValueError( + f"priority out of range [{MIN_PRIORITY}, {MAX_PRIORITY}]: {priority!r}" + ) with self._cursor() as cur: cur.execute( "INSERT INTO pg_queue_message " - "(queue_name, message, org_id, enqueued_at, vt, read_ct) " - "VALUES (%s, %s::jsonb, %s, now(), now(), 0) RETURNING msg_id", + "(queue_name, message, org_id, priority, enqueued_at, vt, read_ct) " + "VALUES (%s, %s::jsonb, %s, %s, now(), now(), 0) RETURNING msg_id", # "" rather than NULL for "no org" — the column is non-null # (string fields shouldn't have two empty values; Django S6553). - (queue_name, json.dumps(message), org_id if org_id is not None else ""), + ( + queue_name, + json.dumps(message), + org_id if org_id is not None else "", + priority, + ), ) msg_id = cur.fetchone()[0] return int(msg_id) @@ -182,7 +232,9 @@ def read( if qty <= 0: raise ValueError(f"qty must be positive, got {qty}") with self._cursor() as cur: - cur.execute(_DEQUEUE_SQL, (vt_seconds, queue_name, qty)) + # Param order matches the %s positions in _DEQUEUE_SQL: + # queue_name (locked CTE), qty (LIMIT), vt_seconds (UPDATE SET). + cur.execute(_DEQUEUE_SQL, (queue_name, qty, vt_seconds)) rows = cur.fetchall() return [ QueueMessage(msg_id=int(r[0]), message=r[1], read_ct=int(r[2])) for r in rows diff --git a/workers/tests/test_dispatch_pg.py b/workers/tests/test_dispatch_pg.py index 75d840962f..e86d99193a 100644 --- a/workers/tests/test_dispatch_pg.py +++ b/workers/tests/test_dispatch_pg.py @@ -111,5 +111,40 @@ def test_celery_dispatch_unaffected(self): mock_get.assert_not_called() +class TestDispatchPriorityWiring: + """dispatch() carries fairness.pipeline_priority onto the PG row (mocked).""" + + @staticmethod + def _capture_send(monkeypatch): + captured: dict = {} + + class _Client: + def send(self, queue_name, payload, **kwargs): + captured.update(queue=queue_name, **kwargs) + return 7 + + monkeypatch.setenv(ENABLED_TASKS_ENV, "leaf_task") + monkeypatch.setattr(dispatch_mod, "_get_pg_client", lambda: _Client()) + return captured + + def test_priority_from_fairness(self, monkeypatch): + captured = self._capture_send(monkeypatch) + fairness = FairnessKey( + org_id="o", workload_type=WorkloadType.API, pipeline_priority=8 + ) + dispatch("leaf_task", fairness=fairness) + assert captured["priority"] == 8 + assert captured["org_id"] == "o" + + def test_no_fairness_uses_neutral_defaults(self, monkeypatch): + # Bare dispatch → org_id None (client coerces to "") + DEFAULT_PRIORITY. + from queue_backend.fairness import DEFAULT_PRIORITY + + captured = self._capture_send(monkeypatch) + dispatch("leaf_task") + assert captured["priority"] == DEFAULT_PRIORITY + assert captured["org_id"] is None + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/workers/tests/test_pg_queue_client.py b/workers/tests/test_pg_queue_client.py index 20bb72a4ba..4a473af01d 100644 --- a/workers/tests/test_pg_queue_client.py +++ b/workers/tests/test_pg_queue_client.py @@ -20,6 +20,7 @@ import psycopg2 import pytest +from queue_backend.fairness import DEFAULT_PRIORITY, MAX_PRIORITY, MIN_PRIORITY from queue_backend.pg_queue import PgQueueClient, QueueMessage from queue_backend.pg_queue.connection import create_pg_connection @@ -68,14 +69,36 @@ def test_send_coerces_missing_org_to_empty_string(self): _, params = cur.execute.call_args.args assert params[2] == "" + def test_send_writes_priority(self): + # Default is the neutral DEFAULT_PRIORITY; an explicit value passes through. + conn, cur = _mock_conn(fetchone=(1,)) + PgQueueClient(conn=conn).send("q1", {"a": 1}) + sql, params = cur.execute.call_args.args + assert "priority" in sql + assert params[3] == DEFAULT_PRIORITY + + conn, cur = _mock_conn(fetchone=(2,)) + PgQueueClient(conn=conn).send("q1", {"a": 1}, priority=9) + _, params = cur.execute.call_args.args + assert params[3] == 9 + + @pytest.mark.parametrize("bad", [0, -1, 11, 99]) + def test_send_rejects_out_of_range_priority(self, bad): + # An out-of-range priority would silently jump/sink the row in the + # priority DESC claim order — reject at the write boundary. + conn, _ = _mock_conn(fetchone=(1,)) + with pytest.raises(ValueError, match="priority out of range"): + PgQueueClient(conn=conn).send("q1", {"a": 1}, priority=bad) + def test_read_runs_skip_locked_dequeue(self): conn, cur = _mock_conn(fetchall=[(7, {"k": "v"}, 1)]) msgs = PgQueueClient(conn=conn).read("q1", vt_seconds=15, qty=3) sql, params = cur.execute.call_args.args assert "FOR UPDATE SKIP LOCKED" in sql assert "UPDATE pg_queue_message" in sql - # Param order follows the %s positions: vt_seconds, queue_name, qty. - assert params == (15, "q1", 3) + assert "ORDER BY priority DESC" in sql # fairness L3 claim order + # Param order follows the %s positions: queue_name, qty, vt_seconds. + assert params == ("q1", 3, 15) assert msgs == [QueueMessage(msg_id=7, message={"k": "v"}, read_ct=1)] conn.commit.assert_called_once() @@ -236,8 +259,9 @@ def boom(**_): @pytest.fixture def queue_name(pg_conn): - # Unique per test for isolation; clean up rows afterwards. - name = f"test_q_{os.getpid()}_{int(time.time() * 1000)}" + # Unique per test for isolation (uuid — a ms timestamp collides when + # fast tests run within the same millisecond); clean up rows afterwards. + name = f"test_q_{os.getpid()}_{uuid.uuid4().hex}" yield name # A failed test body can leave the connection in an aborted transaction; # roll back first so this cleanup doesn't raise InFailedSqlTransaction. @@ -264,6 +288,111 @@ def test_read_hides_message_for_vt(self, pg_conn, queue_name): # Second read within vt sees nothing. assert client.read(queue_name, vt_seconds=30, qty=10) == [] + def test_priority_orders_dequeue(self, pg_conn, queue_name): + # Higher priority is claimed first; FIFO (msg_id) within a priority — + # regardless of enqueue order. Read one at a time (the default + # batch_size=1 path), so each claim selects the current top-priority row. + client = PgQueueClient(conn=pg_conn) + client.send(queue_name, {"n": "low1"}, priority=1) + client.send(queue_name, {"n": "high"}, priority=9) + client.send(queue_name, {"n": "low2"}, priority=1) + client.send(queue_name, {"n": "mid"}, priority=5) + claimed = [] + for _ in range(4): + msgs = client.read(queue_name, vt_seconds=30, qty=1) + assert len(msgs) == 1 + claimed.append(msgs[0].message["n"]) + client.delete(msgs[0].msg_id) + assert claimed == ["high", "mid", "low1", "low2"] + + def test_priority_orders_batch_claim(self, pg_conn, queue_name): + # A batched claim (qty > 1) returns the batch in priority order too — + # the CTE re-sorts RETURNING, which is otherwise unspecified. + client = PgQueueClient(conn=pg_conn) + client.send(queue_name, {"n": "a"}, priority=1) + client.send(queue_name, {"n": "b"}, priority=9) + client.send(queue_name, {"n": "c"}, priority=5) + msgs = client.read(queue_name, vt_seconds=30, qty=10) + assert [m.message["n"] for m in msgs] == ["b", "c", "a"] + + def test_batch_fifo_within_priority_band(self, pg_conn, queue_name): + # Two rows per band, interleaved enqueue → strict (band DESC, msg_id ASC). + client = PgQueueClient(conn=pg_conn) + for label, prio in [("9a", 9), ("1a", 1), ("9b", 9), ("1b", 1)]: + client.send(queue_name, {"n": label}, priority=prio) + msgs = client.read(queue_name, vt_seconds=30, qty=10) + assert [m.message["n"] for m in msgs] == ["9a", "9b", "1a", "1b"] + + def test_visible_low_priority_beats_invisible_high(self, pg_conn, queue_name): + # A claimed-but-unacked high-priority row (future vt) must not block a + # visible lower-priority row — exercises vt × priority interaction. + client = PgQueueClient(conn=pg_conn) + high_id = client.send(queue_name, {"n": "high"}, priority=9) + # Claim the high row → its vt jumps 30s ahead (now invisible). + assert [m.msg_id for m in client.read(queue_name, vt_seconds=30, qty=1)] == [ + high_id + ] + client.send(queue_name, {"n": "low"}, priority=1) + msgs = client.read(queue_name, vt_seconds=30, qty=1) + assert [m.message["n"] for m in msgs] == ["low"] + + def test_concurrent_claims_never_exceed_qty(self, pg_conn, queue_name): + # The CTE FROM-join claims exactly qty even under concurrent writers; + # the old IN(SELECT ... LIMIT) form could over-claim via EvalPlanQual. + # Two readers drain a backlog in parallel; no batch may exceed qty. + import threading + + client_a = PgQueueClient(conn=pg_conn) + for i in range(50): + client_a.send(queue_name, {"i": i}) + conn_b = create_pg_connection(env_prefix="TEST_DB_") + violations: list[int] = [] + + def drain(client): + while True: + msgs = client.read(queue_name, vt_seconds=30, qty=1) + if not msgs: + return + if len(msgs) > 1: # over-claim → the bug this rewrite fixes + violations.append(len(msgs)) + for m in msgs: + client.delete(m.msg_id) + + try: + worker = threading.Thread(target=drain, args=(PgQueueClient(conn=conn_b),)) + worker.start() + drain(client_a) + worker.join(timeout=15) + # Assert termination: a hung drain must fail the test, not pass + # silently while conn_b.close() races its in-flight queries. + assert not worker.is_alive(), "drain worker did not finish within 15s" + finally: + conn_b.close() + assert violations == [] + + def test_db_check_constraint_matches_fairness_bounds(self, pg_conn, queue_name): + # The DB CheckConstraint is the backstop for any raw/ORM writer that + # bypasses send()'s guard. It also pins the constraint to the app's + # fairness range: models.py and fairness.py live in separate codebases + # that can't import each other, so this boundary check is the guard + # against silent drift — in-range accepted, just outside rejected. + def _raw_insert(prio): + with pg_conn.cursor() as cur: + cur.execute( + "INSERT INTO pg_queue_message " + "(queue_name, message, org_id, priority, enqueued_at, vt, read_ct) " + "VALUES (%s, '{}'::jsonb, '', %s, now(), now(), 0)", + (queue_name, prio), + ) + + for prio in (MIN_PRIORITY, MAX_PRIORITY): # in-range boundaries accepted + _raw_insert(prio) + pg_conn.commit() + for prio in (MIN_PRIORITY - 1, MAX_PRIORITY + 1): # out-of-range rejected + with pytest.raises(psycopg2.errors.CheckViolation): + _raw_insert(prio) + pg_conn.rollback() + def test_vt_expiry_redelivers(self, pg_conn, queue_name): client = PgQueueClient(conn=pg_conn) client.send(queue_name, {"n": 1}) From 7c7617b3558cc7be7cc8b45df9c69dbca4128b58 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:37:36 +0530 Subject: [PATCH 09/65] =?UTF-8?q?UN-3548=20[FEAT]=20PgBarrier=20=E2=80=94?= =?UTF-8?q?=20Postgres=20fan-in=20barrier=20(3rd=20WORKER=5FBARRIER=5FBACK?= =?UTF-8?q?END)=20(#2053)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3548 [FEAT] PgBarrier — Postgres fan-in barrier (3rd WORKER_BARRIER_BACKEND) Add a Postgres Barrier substrate selected by WORKER_BARRIER_BACKEND=pg (default stays chord). Moves the fan-in aggregation ("wait for N header tasks, then fire the callback with their results") onto a pg_barrier_state row — the same DB that holds the PG queue, so an execution can coordinate without Redis/RabbitMQ. The 9e pipeline on-ramp primitive. Mirrors RedisDecrBarrier 1:1 — same Barrier protocol, fairness plumbing, Celery-dispatched header tasks with .link/.link_error, empty->None, missing-execution_id->raise, mid-loop dispatch cleanup. Defaults-off, zero behaviour change until the flag flips. - Schema (backend/pg_queue): pg_barrier_state (execution_id PK, remaining, results jsonb, aborted, expires_at) + migration 0004. - pg_barrier.py: PgBarrier + barrier_pg_decr_and_check / barrier_pg_abort. Atomic decrement is ONE statement (UPDATE ... SET remaining = remaining-1, results = results || jsonb_build_array(%s) ... RETURNING remaining, results, aborted) — row lock serialises concurrent decrements so exactly one sees 0; no Lua. Guards: reads aborted in the same statement (never fires partial), row-missing / negative-remaining clean up without firing, callback dispatched BEFORE row delete. Orphan bound via expires_at + opportunistic sweep in enqueue (periodic sweep is the backstop). - __init__.py: BarrierBackend.PG -> PgBarrier() in get_barrier(). Tests: protocol shape, TTL env validation, enqueue (upsert/links/fairness/ stale-reset/expiry-sweep/mid-loop-cleanup), decr paths (pending/complete-fires/ aborted/negative/missing/unserialisable), abort (claim+delete/dedup), and a real two-connection decrement-atomicity check (exactly one sees 0). Selector PG case. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3548 [FIX] Address PR #2053 review feedback High: - Abort is now ONE atomic statement: `WITH claimed AS (DELETE ... RETURNING) ...` — claim+teardown in a single transaction (no claimed-but-not-deleted window; a crash rolls back so a sibling retries). This makes the `aborted` column redundant — dropped it; the decrement's "row missing -> abandoned" branch now covers the failed-task case. The callback can only fire when remaining hits 0 (all tasks succeeded), so a failed task (which deletes the row) can never let a partial-results fire. - Dropped the per-enqueue global orphan sweep (unbounded DELETE on the hot path, deadlock-prone, shared the UPSERT txn). Reclaim is a future periodic sweep. - A NUL byte survives json.dumps but jsonb rejects it -> catch the DataError and tear the barrier down (fail fast) instead of hanging to expiry. Medium: - Post-dispatch row delete is best-effort (logged, not raised) so a delete error can't mask the already-fired callback; documented the no-double-fire invariant (last decrement + max_retries=0). - Added a DB CheckConstraint (expires_at > created_at) — the one writer-proof invariant; Meta comment warns off a `remaining >= 0` check (teardown needs negative). Softened the "periodic sweep" comments to future/not-yet-shipped. Low: - Extracted shared `barrier_ttl_seconds()` + `CallbackDescriptor` into barrier.py; both backends import them (redis keeps back-compat aliases). signature_kwargs dict instead of inline ** spread. Atomicity comment notes the per-transaction premise. Tests: callback-dispatch-failure-preserves-row; decrement-after-abort-no-fire; atomicity through barrier_pg_decr_and_check (two threads, exactly one fires); list-result-as-single-element; NUL-byte teardown; DB-constraint; max_retries=0. 92 barrier tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3548 [DOCS] Drop stale aborted-column reference in PgBarrier docstring The wire-model docstring's enqueue step still listed `aborted = false` as an UPSERT column after the column was removed (abort now dedups via DELETE … RETURNING / row existence). Remove it so a reader doesn't hunt for — or re-add — a column that no longer exists. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../0004_pgbarrierstate_and_more.py | 36 ++ backend/pg_queue/models.py | 49 ++ workers/queue_backend/__init__.py | 11 + workers/queue_backend/barrier.py | 48 +- workers/queue_backend/pg_barrier.py | 419 ++++++++++++++++++ workers/queue_backend/redis_barrier.py | 68 +-- .../tests/test_barrier_backend_selection.py | 7 + workers/tests/test_pg_barrier.py | 407 +++++++++++++++++ 8 files changed, 987 insertions(+), 58 deletions(-) create mode 100644 backend/pg_queue/migrations/0004_pgbarrierstate_and_more.py create mode 100644 workers/queue_backend/pg_barrier.py create mode 100644 workers/tests/test_pg_barrier.py diff --git a/backend/pg_queue/migrations/0004_pgbarrierstate_and_more.py b/backend/pg_queue/migrations/0004_pgbarrierstate_and_more.py new file mode 100644 index 0000000000..c79b29a75a --- /dev/null +++ b/backend/pg_queue/migrations/0004_pgbarrierstate_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.1 on 2026-06-15 06:55 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0003_pgqueuemessage_pg_queue_message_priority_range"), + ] + + operations = [ + migrations.CreateModel( + name="PgBarrierState", + fields=[ + ("execution_id", models.TextField(primary_key=True, serialize=False)), + ("remaining", models.IntegerField()), + ("results", models.JSONField(default=list)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("expires_at", models.DateTimeField()), + ], + options={ + "db_table": "pg_barrier_state", + "indexes": [ + models.Index(fields=["expires_at"], name="pg_barrier_expires_idx") + ], + }, + ), + migrations.AddConstraint( + model_name="pgbarrierstate", + constraint=models.CheckConstraint( + check=models.Q(("expires_at__gt", models.F("created_at"))), + name="pg_barrier_expires_after_created", + ), + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index 8b3386cb27..0eeb75e635 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -66,3 +66,52 @@ class Meta: name="pg_queue_message_dequeue_idx", ) ] + + +class PgBarrierState(models.Model): + """Per-execution fan-in barrier state for ``PgBarrier`` (the Postgres + ``WORKER_BARRIER_BACKEND``). + + One row per in-flight barrier (keyed by ``execution_id``). The worker-side + ``barrier_pg_decr_and_check`` link task atomically decrements ``remaining`` + and appends to ``results`` in a single ``UPDATE … RETURNING``; the task that + drives ``remaining`` to 0 dispatches the aggregating callback and deletes the + row. A header-task failure aborts the barrier by deleting the row outright + (``DELETE … RETURNING`` — atomic claim+teardown), so the callback can never + fire with partial results. ``expires_at`` bounds an orphaned barrier (header + tasks that never complete); a periodic sweep job (not yet implemented) is the + intended reclaim backstop. + + Managed=True / generated migration — no DB-side function, extension-free + (UN-3533), same posture as ``PgQueueMessage``. + """ + + execution_id = models.TextField(primary_key=True) + # Header tasks still pending. The last task to decrement it to 0 fires the + # callback. A value < 0 (decrement after expiry/cleanup) means the barrier + # was already torn down — the task cleans up without firing. + remaining = models.IntegerField() + # Aggregated header-task results, appended in completion order (JSONB array). + results = models.JSONField(default=list) + created_at = models.DateTimeField(default=timezone.now) + # Orphan bound (Redis-TTL equivalent): a barrier whose header tasks never + # complete is reclaimable past this. Must exceed the longest execution + # wall-clock, same budgeting as WORKER_BARRIER_KEY_TTL_SECONDS. + expires_at = models.DateTimeField() + + class Meta: + db_table = "pg_barrier_state" + constraints = [ + # The one writer-proof invariant (the worker SQL can't import this + # model). Deliberately NO `remaining >= 0` check — the teardown path + # relies on `remaining` going negative as a "barrier already gone" + # signal, so a non-negative constraint would break it. + models.CheckConstraint( + check=models.Q(expires_at__gt=models.F("created_at")), + name="pg_barrier_expires_after_created", + ), + ] + indexes = [ + # Drives the (future) periodic expiry-sweep job. + models.Index(fields=["expires_at"], name="pg_barrier_expires_idx"), + ] diff --git a/workers/queue_backend/__init__.py b/workers/queue_backend/__init__.py index c4310f5eff..d1c9154aa6 100644 --- a/workers/queue_backend/__init__.py +++ b/workers/queue_backend/__init__.py @@ -41,6 +41,11 @@ from .decorator import worker_task from .dispatch import dispatch from .fairness import FairnessKey +from .pg_barrier import ( + PgBarrier, + barrier_pg_abort, + barrier_pg_decr_and_check, +) from .redis_barrier import ( RedisDecrBarrier, barrier_abort, @@ -54,10 +59,13 @@ "BarrierHandle", "CeleryChordBarrier", "FairnessKey", + "PgBarrier", "QueueBackend", "RedisDecrBarrier", "barrier_abort", "barrier_decr_and_check", + "barrier_pg_abort", + "barrier_pg_decr_and_check", "dispatch", "get_barrier", "select_backend", @@ -77,6 +85,7 @@ class BarrierBackend(StrEnum): CHORD = "chord" REDIS = "redis" + PG = "pg" def get_barrier() -> Barrier: @@ -110,6 +119,8 @@ def get_barrier() -> Barrier: return CeleryChordBarrier() if backend is BarrierBackend.REDIS: return RedisDecrBarrier() + if backend is BarrierBackend.PG: + return PgBarrier() # Unreachable — StrEnum constructor would have raised above for # anything not in the enum. Defensive raise so the type checker # sees an exhaustive match. diff --git a/workers/queue_backend/barrier.py b/workers/queue_backend/barrier.py index 38b4045c16..d56348ce42 100644 --- a/workers/queue_backend/barrier.py +++ b/workers/queue_backend/barrier.py @@ -35,7 +35,8 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Protocol +import os +from typing import TYPE_CHECKING, Any, Protocol, TypedDict from celery import chord @@ -47,6 +48,51 @@ logger = logging.getLogger(__name__) +# Shared barrier-key TTL — both the Redis and PG backends bound an orphaned +# barrier (header tasks that never complete) by the same env var, since only one +# backend is active per deployment. One definition here prevents drift. +_DEFAULT_BARRIER_TTL_SECONDS = 6 * 60 * 60 # 6h + + +def barrier_ttl_seconds() -> int: + """Barrier TTL from ``WORKER_BARRIER_KEY_TTL_SECONDS`` (default 6h). + + Read at call time so tests can flip it. Invalid / non-positive values raise, + matching ``get_barrier()``'s loud-on-misconfig posture — a TTL shorter than + execution wall-clock would tear barriers down early (spurious behaviour). + """ + raw = os.getenv("WORKER_BARRIER_KEY_TTL_SECONDS") + if raw is None: + return _DEFAULT_BARRIER_TTL_SECONDS + try: + value = int(raw) + except ValueError as exc: + raise ValueError( + f"WORKER_BARRIER_KEY_TTL_SECONDS={raw!r} is not an integer. Unset it " + f"to default to {_DEFAULT_BARRIER_TTL_SECONDS}s (6h)." + ) from exc + if value <= 0: + raise ValueError( + f"WORKER_BARRIER_KEY_TTL_SECONDS={value} must be a positive integer. " + f"Unset it to default to {_DEFAULT_BARRIER_TTL_SECONDS}s (6h)." + ) + return value + + +class CallbackDescriptor(TypedDict): + """Serialisable aggregating-callback spec baked into a barrier link signature. + + Crosses a Celery serialisation boundary (producer → broker → worker), so the + four-key contract is typed to catch a typo/rename before it surfaces as a + remote ``KeyError`` mid-aggregation. Shared by both the Redis and PG + backends. ``fairness_headers`` is ``None`` when the producer passed no key. + """ + + task_name: str + kwargs: dict[str, Any] + queue: str + fairness_headers: dict[str, Any] | None + class Barrier(Protocol): """Fan-out-then-callback primitive. diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py new file mode 100644 index 0000000000..4ebc46be87 --- /dev/null +++ b/workers/queue_backend/pg_barrier.py @@ -0,0 +1,419 @@ +"""PG fan-in barrier — Postgres substrate for the ``Barrier`` Protocol. + +Third ``WORKER_BARRIER_BACKEND`` option (``pg``) alongside ``chord`` and +``redis``. Mirrors :class:`~queue_backend.redis_barrier.RedisDecrBarrier` +exactly — same ``enqueue`` signature, same ``BarrierHandle | None`` contract, +same fairness plumbing, same Celery-dispatched header tasks with +``.link``/``.link_error`` — but moves the **aggregation** ("wait for N tasks, +then fire the callback with their results") from a Redis ``DECR`` counter to a +Postgres row. Selected at runtime by ``queue_backend.get_barrier``; default +stays ``chord``. + +**Why a Postgres substrate.** It lets an execution coordinate in the *same* +Postgres that holds the PG queue — no Redis (or RabbitMQ chord backend) needed +for the fan-in. The transport for the header tasks themselves is unchanged +(still Celery); only the coordination moves. + +**Wire model.** + +1. ``enqueue``: UPSERT one ``pg_barrier_state`` row (``remaining = N``, + ``results = []``, ``expires_at = now() + ttl``) — the UPSERT clears any stale + state from a prior run reusing the same ``execution_id``. Each header task is + dispatched with + ``.link(barrier_pg_decr_and_check)`` (success) and + ``.link_error(barrier_pg_abort)`` (failure). +2. Per-task success: ``barrier_pg_decr_and_check`` runs ONE atomic statement — + ``UPDATE … SET remaining = remaining - 1, results = results || + jsonb_build_array(result) … RETURNING remaining, results``. The row lock + serialises concurrent decrements, so exactly one task observes ``remaining = + 0``; that task dispatches the callback with the aggregated results, then + deletes the row. (No Lua — a single ``UPDATE … RETURNING`` is atomic in + Postgres. The guarantee relies on each decrement committing in its own + transaction — do NOT batch decrements into a shared transaction, or the row + lock would hold and the serialisation that makes exactly one see 0 breaks.) +3. Per-task failure: ``barrier_pg_abort`` runs as a ``link_error``. It tears the + barrier down with ONE atomic statement (``DELETE … RETURNING`` in a single + transaction): the row's existence is the dedup token, so N concurrent + failures collapse to a single cleanup, and a crash mid-abort rolls back + (leaving the row for a sibling to retry) — there is no claimed-but-not-deleted + window. Mirrors chord's default error semantic (callback not invoked on + header failure). +4. Orphan bound: like the Redis backend's key TTL, ``expires_at`` bounds a + barrier whose header tasks never complete. **No expiry reclaim ships in this + phase** — a periodic sweep job (keyed on ``pg_barrier_expires_idx``) is the + intended backstop and is future work. + +**Failure-masking guards.** The callback can only fire when ``remaining`` hits +exactly 0, which requires ALL N header tasks to have decremented — i.e. all +succeeded. A failed task runs the abort (which deletes the row) instead of a +decrement, so the count never reaches 0 and a late in-flight decrement finds no +row → abandons. A decrement that drives ``remaining`` negative (expiry/replay) +also cleans up without firing. The callback is dispatched BEFORE the row is +deleted, so a callback ``apply_async`` failure leaves the row (and its expiry) +in place rather than stranding the execution; the post-dispatch delete is +best-effort (logged, not raised) since the callback has already fired. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import threading +from collections.abc import Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import psycopg2 + +from .barrier import CallbackDescriptor, barrier_ttl_seconds +from .decorator import worker_task +from .fairness import FairnessKey +from .handle import BarrierHandle +from .pg_queue.connection import create_pg_connection + +if TYPE_CHECKING: + from celery.canvas import Signature + from psycopg2.extensions import connection as PgConnection + +logger = logging.getLogger(__name__) + + +# Thread-local owned connection (prefork → one per child; thread-local keeps it +# correct under -P threads too, since a libpq connection is not concurrency-safe +# across threads). Self-recovers a dropped socket / PgBouncer recycle — same +# posture as queue_backend.dispatch / PgQueueClient. +_local = threading.local() + + +def _get_conn() -> PgConnection: + conn = getattr(_local, "conn", None) + if conn is None or conn.closed: + conn = create_pg_connection(env_prefix="DB_") + _local.conn = conn + return conn + + +@contextlib.contextmanager +def _cursor() -> Iterator[Any]: + """Yield a cursor; commit on success, roll back + recover on error.""" + conn = _get_conn() + try: + with conn.cursor() as cur: + yield cur + conn.commit() + except Exception as exc: + conn_dead = isinstance(exc, (psycopg2.OperationalError, psycopg2.InterfaceError)) + try: + conn.rollback() + except Exception: + conn_dead = True + if conn_dead or conn.closed: + with contextlib.suppress(Exception): + conn.close() + _local.conn = None + raise + + +def _delete_barrier(execution_id: str) -> None: + with _cursor() as cur: + cur.execute( + "DELETE FROM pg_barrier_state WHERE execution_id = %s", (execution_id,) + ) + + +@dataclass(frozen=True, slots=True) +class _PgBarrierHandle: + """Minimal ``BarrierHandle`` — ``id`` is the execution id (what call sites + log for chord-id tracing), same as ``_RedisBarrierHandle``. + """ + + id: str + + +class PgBarrier: + """``Barrier`` implementation via a Postgres ``pg_barrier_state`` row. + + Drop-in for ``CeleryChordBarrier`` / ``RedisDecrBarrier`` from the call + sites' perspective. See the module docstring for the wire model and the + failure-masking guards. + """ + + def enqueue( + self, + header_tasks: list[Signature], + *, + callback_task_name: str, + callback_kwargs: dict[str, Any], + callback_queue: str, + app_instance: Any, + fairness: FairnessKey | None = None, + ) -> BarrierHandle | None: + """See :class:`queue_backend.barrier.Barrier.enqueue`. + + Empty ``header_tasks`` → ``None`` (caller owns the zero-files contract); + any substrate failure raises. ``app_instance`` is accepted for Protocol + parity but unused — the callback is built inside the link task via + ``current_app`` so it runs against the worker's app. + """ + del app_instance # Protocol parity; callback built in the link task. + if not header_tasks: + logger.info( + f"[exec:{callback_kwargs.get('execution_id')}] " + f"[pipeline:{callback_kwargs.get('pipeline_id')}] " + "Zero header tasks detected — skipping barrier enqueue " + "(parent should handle pipeline status updates directly)" + ) + return None + + execution_id = callback_kwargs.get("execution_id") + if not execution_id: + raise ValueError( + "PgBarrier requires execution_id in callback_kwargs — it's the " + "primary key of the per-execution pg_barrier_state row" + ) + execution_id = str(execution_id) + + try: + fairness_headers = fairness.as_header() if fairness else None + callback_descriptor: CallbackDescriptor = { + "task_name": callback_task_name, + "kwargs": callback_kwargs, + "queue": callback_queue, + "fairness_headers": fairness_headers, + } + ttl_seconds = barrier_ttl_seconds() + + with _cursor() as cur: + # UPSERT clears any leftover state from a prior run with this id. + # No inline expiry sweep here — an unbounded global DELETE on the + # enqueue hot path risks lock contention / deadlocks between + # concurrent enqueues; orphan reclaim is a separate (future) + # periodic sweep keyed on pg_barrier_expires_idx. + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, remaining, results, created_at, expires_at) " + "VALUES (%s, %s, '[]'::jsonb, now(), " + " now() + make_interval(secs => %s)) " + "ON CONFLICT (execution_id) DO UPDATE SET " + " remaining = EXCLUDED.remaining, results = '[]'::jsonb, " + " created_at = now(), expires_at = EXCLUDED.expires_at", + (execution_id, len(header_tasks), ttl_seconds), + ) + + link_signature = barrier_pg_decr_and_check.s( + execution_id=execution_id, + callback_descriptor=callback_descriptor, + ) + link_error_signature = barrier_pg_abort.s(execution_id=execution_id) + for i, task in enumerate(header_tasks): + try: + cloned = task.clone() + if fairness_headers: + cloned.set(headers=fairness_headers) + cloned.link(link_signature) + cloned.link_error(link_error_signature) + cloned.apply_async() + except Exception: + # Mid-loop dispatch failure: i of N never reached the broker, + # so the counter can't reach 0. Delete the row so in-flight + # links' decrement finds no row and cleans up; re-raise so the + # caller marks the workflow ERROR. + with contextlib.suppress(Exception): + _delete_barrier(execution_id) + logger.exception( + f"[exec:{execution_id}] apply_async failed at task " + f"{i}/{len(header_tasks)}; barrier row deleted to prevent " + f"spurious callback fires from the orphan tasks' links." + ) + raise + + logger.info( + f"Barrier enqueued via PgBarrier — exec_id={execution_id}, " + f"header_tasks={len(header_tasks)}, callback={callback_task_name}, " + f"queue={callback_queue}" + ) + return _PgBarrierHandle(id=execution_id) + + except Exception: + logger.exception( + f"[exec:{execution_id}] " + f"[pipeline:{callback_kwargs.get('pipeline_id')}] " + f"Failed to enqueue barrier via Postgres " + f"(callback={callback_task_name}, queue={callback_queue}, " + f"header_tasks={len(header_tasks)})" + ) + raise + + +@worker_task(name="barrier_pg_decr_and_check", max_retries=0) +def barrier_pg_decr_and_check( + result: Any, + *, + execution_id: str, + callback_descriptor: CallbackDescriptor, +) -> dict[str, Any]: + """Per-task ``link`` callback for :class:`PgBarrier`. + + Atomically appends this task's result and decrements ``remaining``. The + single task that drives ``remaining`` to 0 dispatches the aggregating + callback (then deletes the row). ``max_retries=0`` — a Celery retry would + replay the decrement and corrupt the count; an orphaned barrier is bounded + by ``expires_at`` instead. + """ + from celery import current_app + + try: + # No default=str — a non-JSON-safe leaf must fail loudly here (it would + # signal a BatchExecutionResult.to_dict() typed-boundary regression). + result_json = json.dumps(result) + except (TypeError, ValueError): + logger.exception( + f"[exec:{execution_id}] Header task result is not JSON-serialisable " + f"— barrier aggregation cannot proceed (typed-boundary regression)." + ) + raise + + # jsonb_build_array(...) appends exactly one element regardless of the + # result's shape (``||`` would concatenate if the result were itself a list). + try: + with _cursor() as cur: + cur.execute( + "UPDATE pg_barrier_state " + " SET remaining = remaining - 1, " + " results = results || jsonb_build_array(%s::jsonb) " + " WHERE execution_id = %s " + "RETURNING remaining, results", + (result_json, execution_id), + ) + row = cur.fetchone() + except psycopg2.DataError: + # json.dumps accepts a few bytes jsonb rejects — notably a NUL (0x00) + # in a string. The cast above then raises, the decrement never lands, and + # the barrier would hang to expires_at (~6h). Tear it down so the + # execution fails fast and visibly instead. + logger.exception( + f"[exec:{execution_id}] Header result rejected by jsonb (e.g. a NUL " + f"byte) — tearing down the barrier so the execution fails fast " + f"rather than hanging until expiry." + ) + with contextlib.suppress(Exception): + _delete_barrier(execution_id) + raise + + if row is None: + # Barrier already torn down (a header failed → abort deleted the row, or + # an expiry sweep removed it). No callback. + logger.error( + f"[exec:{execution_id}] PgBarrier decrement found no row — barrier " + f"already torn down. No callback dispatched." + ) + return {"status": "abandoned", "remaining": None} + + remaining, all_results = int(row[0]), row[1] + logger.info(f"[exec:{execution_id}] PgBarrier decrement → remaining={remaining}") + + if remaining > 0: + return {"status": "pending", "remaining": remaining} + + if remaining < 0: + # Decremented past 0 — expiry/replay. Clean up, no fire. + _delete_barrier(execution_id) + logger.error( + f"[exec:{execution_id}] PgBarrier abandoned — remaining={remaining} " + f"(expired or torn down). No callback dispatched; execution likely " + f"in an inconsistent terminal state and needs investigation." + ) + return {"status": "abandoned", "remaining": remaining} + + # remaining == 0: we are the last task. psycopg2 decodes the jsonb array to + # a Python list already — no per-element json.loads needed. Build the kwargs + # explicitly (headers only when truthy) — clearer than an inline ** spread, + # matching CeleryChordBarrier's idiom. + signature_kwargs: dict[str, Any] = { + "args": [all_results], + "kwargs": callback_descriptor["kwargs"], + "queue": callback_descriptor["queue"], + } + if callback_descriptor.get("fairness_headers"): + signature_kwargs["headers"] = callback_descriptor["fairness_headers"] + callback_signature = current_app.signature( + callback_descriptor["task_name"], **signature_kwargs + ) + # Dispatch FIRST; delete the row only after dispatch succeeds, so a callback + # apply_async failure leaves the row (and its expiry) in place rather than + # stranding the execution with no state and no recovery path. + callback_result = callback_signature.apply_async() + # The post-dispatch delete must not mask the successful dispatch: the callback + # already fired, so a delete error here is logged (not raised) — the row + # lingers until expiry rather than re-running the callback. No double-fire is + # possible: this is the last decrement (remaining hit 0) and max_retries=0, so + # the link task is never replayed. + try: + _delete_barrier(execution_id) + except Exception: + logger.exception( + f"[exec:{execution_id}] Barrier callback dispatched " + f"(callback_task_id={callback_result.id}) but the post-dispatch row " + f"delete failed — row will be reclaimed at expiry. Callback NOT " + f"re-run (max_retries=0)." + ) + + logger.info( + f"[exec:{execution_id}] Barrier complete — fired callback " + f"{callback_descriptor['task_name']} on {callback_descriptor['queue']} " + f"with {len(all_results)} aggregated results " + f"(callback_task_id={callback_result.id})" + ) + return { + "status": "complete", + "callback_task_id": callback_result.id, + "aggregated_count": len(all_results), + } + + +@worker_task(name="barrier_pg_abort", max_retries=0) +def barrier_pg_abort( + request: Any = None, + exc: Any = None, + traceback: Any = None, + *, + execution_id: str, +) -> dict[str, Any]: + """``link_error`` callback: a header task failed → tear down barrier state. + + Mirrors chord's default error semantic (callback not invoked on header + failure). The claim and the teardown are a SINGLE atomic statement — a + ``DELETE … RETURNING`` in one transaction. The row's existence is the dedup + token: the first abort deletes it (and so "wins"); every concurrent sibling + finds nothing to delete and short-circuits. Because it's one transaction, a + crash/failure mid-abort rolls the whole thing back (the row survives) so a + sibling can retry — there is no "claimed-but-not-deleted" window. A late + in-flight successful decrement then finds no row and abandons (no partial + fire). + + The ``request``/``exc``/``traceback`` defaults keep the old-style + ``errback(task_id)`` invocation (mixed-version deploy / ``bind=True``) from + raising a ``TypeError``. This task does NOT drive workflow terminal status — + the outer orchestrators own that. + """ + del request, exc, traceback # logged by the outer task; unused here + with _cursor() as cur: + cur.execute( + "WITH claimed AS (" + " DELETE FROM pg_barrier_state WHERE execution_id = %s " + " RETURNING execution_id" + ") SELECT execution_id FROM claimed", + (execution_id,), + ) + claimed = cur.fetchone() is not None + + if not claimed: + # Another failure already aborted this execution (or it's already gone). + return {"status": "already_aborted", "execution_id": execution_id} + + logger.error( + f"[exec:{execution_id}] PgBarrier aborted — a header task failed; " + f"barrier state cleaned up (aggregating callback will not fire)." + ) + return {"status": "aborted", "execution_id": execution_id} diff --git a/workers/queue_backend/redis_barrier.py b/workers/queue_backend/redis_barrier.py index 5f79db9cd8..346e44f8b7 100644 --- a/workers/queue_backend/redis_barrier.py +++ b/workers/queue_backend/redis_barrier.py @@ -64,12 +64,17 @@ import logging import os from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any from celery.canvas import Signature from unstract.core.cache.redis_client import create_redis_client +from .barrier import ( + _DEFAULT_BARRIER_TTL_SECONDS, + CallbackDescriptor, + barrier_ttl_seconds, +) from .decorator import worker_task from .fairness import FairnessKey from .handle import BarrierHandle @@ -111,41 +116,11 @@ # longer workflows (e.g. multi-step pipelines with chained barriers) # or shorter known max-execution-time should tune via # ``WORKER_BARRIER_KEY_TTL_SECONDS``. -_KEY_TTL_DEFAULT_SECONDS = 6 * 60 * 60 # 6h - - -def _key_ttl_seconds() -> int: - """Read the TTL from env, applying the default only on absence. - - Read at call time (not module import) so a test - ``monkeypatch.setenv`` flips the value without a module reload. - - Invalid values (non-int, negative, zero) **raise**, matching the - posture in ``get_barrier()`` where ``WORKER_BARRIER_BACKEND=rediz`` - raises rather than silently falling back to chord. A misconfigured - TTL shorter than execution wall-clock is a correctness issue per - this file's docstring (would cause spurious callback fires) — - operators get the same loud-on-misconfig signal as for the - backend flag. - """ - raw = os.getenv("WORKER_BARRIER_KEY_TTL_SECONDS") - if raw is None: - return _KEY_TTL_DEFAULT_SECONDS - try: - value = int(raw) - except ValueError as e: - raise ValueError( - f"WORKER_BARRIER_KEY_TTL_SECONDS={raw!r} is not an integer. " - f"Unset the env var to default to {_KEY_TTL_DEFAULT_SECONDS}s " - f"(6h)." - ) from e - if value <= 0: - raise ValueError( - f"WORKER_BARRIER_KEY_TTL_SECONDS={value} must be a positive " - f"integer. Unset the env var to default to " - f"{_KEY_TTL_DEFAULT_SECONDS}s (6h)." - ) - return value +# TTL lives in barrier.py (shared with PgBarrier — both bound an orphaned +# barrier by the same env var). Aliased so this module's internal call sites and +# tests keep their names. +_key_ttl_seconds = barrier_ttl_seconds +_KEY_TTL_DEFAULT_SECONDS = _DEFAULT_BARRIER_TTL_SECONDS # Atomic ``RPUSH + EXPIRE + DECR``: returns ``(remaining, results)``. @@ -511,27 +486,6 @@ def enqueue( raise -class CallbackDescriptor(TypedDict): - """Shape of the dict baked into the link signature and re-read on - the worker that runs ``barrier_decr_and_check``. - - This descriptor crosses a serialisation boundary (Celery - ``signature(...)`` → broker → consumer worker), so the four-key - contract is otherwise enforced only by string literals duplicated - across producer and consumer. Typing it as a ``TypedDict`` gives - the type checker a chance to catch typos / renames before they - surface as remote ``KeyError`` mid-aggregation. - - ``fairness_headers`` is always present in the dict; ``None`` when - the producer passed no ``FairnessKey``. - """ - - task_name: str - kwargs: dict[str, Any] - queue: str - fairness_headers: dict[str, Any] | None - - @dataclass(frozen=True, slots=True) class _RedisBarrierHandle: """Minimal ``BarrierHandle`` implementation. diff --git a/workers/tests/test_barrier_backend_selection.py b/workers/tests/test_barrier_backend_selection.py index 7a452652e5..0948814e67 100644 --- a/workers/tests/test_barrier_backend_selection.py +++ b/workers/tests/test_barrier_backend_selection.py @@ -26,6 +26,7 @@ from queue_backend import ( BarrierBackend, CeleryChordBarrier, + PgBarrier, RedisDecrBarrier, get_barrier, ) @@ -39,6 +40,7 @@ def test_chord_value_is_canonical_string(self): documented env value).""" assert BarrierBackend.CHORD.value == "chord" assert BarrierBackend.REDIS.value == "redis" + assert BarrierBackend.PG.value == "pg" def test_str_enum_string_equality(self): """``StrEnum`` members compare equal to their string values — @@ -46,6 +48,7 @@ def test_str_enum_string_equality(self): interchangeably without conversion.""" assert BarrierBackend.CHORD == "chord" assert BarrierBackend.REDIS == "redis" + assert BarrierBackend.PG == "pg" class TestGetBarrierFactory: @@ -66,6 +69,10 @@ def test_redis_selects_redis_decr_barrier(self, monkeypatch): monkeypatch.setenv("WORKER_BARRIER_BACKEND", BarrierBackend.REDIS) assert isinstance(get_barrier(), RedisDecrBarrier) + def test_pg_selects_pg_barrier(self, monkeypatch): + monkeypatch.setenv("WORKER_BARRIER_BACKEND", BarrierBackend.PG) + assert isinstance(get_barrier(), PgBarrier) + def test_unknown_value_raises_loudly(self, monkeypatch): """Silent fallback to default would mask a typo'd production env (``rediz``, ``REDDIS``, ``redis-decr``) — the canary diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py new file mode 100644 index 0000000000..0c1a635c1d --- /dev/null +++ b/workers/tests/test_pg_barrier.py @@ -0,0 +1,407 @@ +"""Tests for :class:`queue_backend.pg_barrier.PgBarrier`. + +Three layers: + +1. **Protocol / TTL** — no DB, no Celery. +2. **Enqueue + link/abort tasks** — a real autocommit Postgres connection is + injected into the module thread-local (the barrier's SQL runs for real); the + Celery header-task dispatch + callback are mocked. Skips if Postgres is + unreachable or the ``pg_barrier_state`` migration is unapplied. +3. **Atomicity** — two real connections race the decrement SQL directly. +""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import psycopg2 +import pytest +from queue_backend import barrier as barrier_mod +from queue_backend import pg_barrier +from queue_backend.fairness import FAIRNESS_HEADER_NAME, FairnessKey, WorkloadType +from queue_backend.handle import BarrierHandle +from queue_backend.pg_barrier import ( + PgBarrier, + barrier_pg_abort, + barrier_pg_decr_and_check, +) +from queue_backend.pg_queue.connection import create_pg_connection + +_CALLBACK = { + "task_name": "process_batch_callback_api", + "kwargs": {"execution_id": "exec-1", "pipeline_id": "pipe-1"}, + "queue": "general", + "fairness_headers": None, +} + + +def _mock_header_task(): + """A header-task Signature whose clone() records link/link_error/apply_async.""" + cloned = MagicMock(name="cloned_signature") + task = MagicMock(name="header_signature") + task.clone.return_value = cloned + return task, cloned + + +# --- Layer 1: protocol shape + TTL (no DB) --- + + +class TestPgBarrierProtocolShape: + def test_satisfies_barrier_protocol(self): + barrier: barrier_mod.Barrier = PgBarrier() + assert callable(getattr(barrier, "enqueue", None)) + + def test_handle_satisfies_barrier_handle(self): + handle: BarrierHandle = pg_barrier._PgBarrierHandle(id="exec-1") + assert handle.id == "exec-1" + assert isinstance(handle.id, str) + + +class TestTtlEnv: + # PgBarrier shares barrier.barrier_ttl_seconds() with the Redis backend. + def test_default_is_six_hours(self, monkeypatch): + monkeypatch.delenv("WORKER_BARRIER_KEY_TTL_SECONDS", raising=False) + assert barrier_mod.barrier_ttl_seconds() == 6 * 60 * 60 + + def test_overridable(self, monkeypatch): + monkeypatch.setenv("WORKER_BARRIER_KEY_TTL_SECONDS", "120") + assert barrier_mod.barrier_ttl_seconds() == 120 + + @pytest.mark.parametrize("bad", ["abc", "0", "-5"]) + def test_invalid_raises(self, monkeypatch, bad): + monkeypatch.setenv("WORKER_BARRIER_KEY_TTL_SECONDS", bad) + with pytest.raises(ValueError, match="WORKER_BARRIER_KEY_TTL_SECONDS"): + barrier_mod.barrier_ttl_seconds() + + +class TestEnqueueShortCircuits: + def test_empty_header_returns_none(self): + # Returns before any DB / dispatch. + assert ( + PgBarrier().enqueue( + [], + callback_task_name="cb", + callback_kwargs={"execution_id": "e"}, + callback_queue="general", + app_instance=None, + ) + is None + ) + + def test_missing_execution_id_raises(self): + with pytest.raises(ValueError, match="execution_id"): + PgBarrier().enqueue( + [_mock_header_task()[0]], + callback_task_name="cb", + callback_kwargs={}, # no execution_id + callback_queue="general", + app_instance=None, + ) + + +# --- Layer 2: enqueue + link/abort with a real injected connection --- + + +@pytest.fixture +def barrier_db(): + """Inject a real autocommit connection into pg_barrier's thread-local. + + Autocommit so each statement commits independently (like the worker tasks in + production) and the barrier's ``_cursor`` commit() is a no-op. The link/abort + tasks run in this same (test) thread, so they see the injected connection. + """ + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + try: + conn = create_pg_connection(env_prefix="TEST_DB_") + except psycopg2.OperationalError as exc: + pytest.skip(f"Postgres not reachable: {exc}") + conn.autocommit = True + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('pg_barrier_state')") + if cur.fetchone()[0] is None: + conn.close() + pytest.skip("pg_barrier_state migration not applied (run backend migrate)") + cur.execute("DELETE FROM pg_barrier_state") + pg_barrier._local.conn = conn + yield conn + with conn.cursor() as cur: + cur.execute("DELETE FROM pg_barrier_state") + conn.close() + pg_barrier._local.conn = None + + +def _row(conn, execution_id): + with conn.cursor() as cur: + cur.execute( + "SELECT remaining, results FROM pg_barrier_state WHERE execution_id = %s", + (execution_id,), + ) + return cur.fetchone() + + +class TestPgBarrierEnqueue: + def test_upsert_creates_row_and_attaches_links(self, barrier_db): + tasks = [_mock_header_task() for _ in range(3)] + handle = PgBarrier().enqueue( + [t for t, _ in tasks], + callback_task_name="cb", + callback_kwargs={"execution_id": "exec-A"}, + callback_queue="general", + app_instance=None, + ) + assert handle.id == "exec-A" + assert _row(barrier_db, "exec-A") == (3, []) + for _, cloned in tasks: + cloned.link.assert_called_once() + cloned.link_error.assert_called_once() + cloned.apply_async.assert_called_once() + + def test_fairness_header_stamped(self, barrier_db): + task, cloned = _mock_header_task() + PgBarrier().enqueue( + [task], + callback_task_name="cb", + callback_kwargs={"execution_id": "exec-F"}, + callback_queue="general", + app_instance=None, + fairness=FairnessKey(org_id="o", workload_type=WorkloadType.API), + ) + headers = cloned.set.call_args.kwargs["headers"] + assert FAIRNESS_HEADER_NAME in headers + + def test_upsert_overwrites_stale_state(self, barrier_db): + # A prior run left a row at remaining=1 with results; the new enqueue + # resets it. + with barrier_db.cursor() as cur: + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, remaining, results, created_at, expires_at) " + "VALUES ('exec-R', 1, '[1,2]'::jsonb, now(), now() + interval '1h')" + ) + task, _ = _mock_header_task() + PgBarrier().enqueue( + [task, _mock_header_task()[0]], + callback_task_name="cb", + callback_kwargs={"execution_id": "exec-R"}, + callback_queue="general", + app_instance=None, + ) + assert _row(barrier_db, "exec-R") == (2, []) + + def test_mid_loop_dispatch_failure_deletes_row(self, barrier_db): + good, _ = _mock_header_task() + bad, bad_cloned = _mock_header_task() + bad_cloned.apply_async.side_effect = RuntimeError("broker down") + with pytest.raises(RuntimeError): + PgBarrier().enqueue( + [good, bad], + callback_task_name="cb", + callback_kwargs={"execution_id": "exec-D"}, + callback_queue="general", + app_instance=None, + ) + assert _row(barrier_db, "exec-D") is None # cleaned up + + +def _seed(conn, execution_id, remaining, *, results="[]"): + with conn.cursor() as cur: + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, remaining, results, created_at, expires_at) " + "VALUES (%s, %s, %s::jsonb, now(), now() + interval '1h')", + (execution_id, remaining, results), + ) + + +class TestDecrAndCheck: + def test_pending_decrements_only(self, barrier_db): + _seed(barrier_db, "exec-P", 3) + out = barrier_pg_decr_and_check( + {"f": 1}, execution_id="exec-P", callback_descriptor=_CALLBACK + ) + assert out["status"] == "pending" + remaining, results = _row(barrier_db, "exec-P") + assert remaining == 2 + assert results == [{"f": 1}] + + def test_complete_fires_callback_with_aggregated_results(self, barrier_db): + _seed(barrier_db, "exec-C", 1, results='[{"f": "a"}]') + with patch("celery.current_app.signature") as sig: + sig.return_value.apply_async.return_value = MagicMock(id="cb-task-1") + out = barrier_pg_decr_and_check( + {"f": "b"}, execution_id="exec-C", callback_descriptor=_CALLBACK + ) + assert out["status"] == "complete" + # Callback got the full aggregated list as its first positional arg. + assert sig.call_args.kwargs["args"] == [[{"f": "a"}, {"f": "b"}]] + assert _row(barrier_db, "exec-C") is None # row deleted after dispatch + + def test_complete_path_passes_fairness_header(self, barrier_db): + _seed(barrier_db, "exec-FH", 1) + descriptor = {**_CALLBACK, "fairness_headers": {FAIRNESS_HEADER_NAME: {"o": 1}}} + with patch("celery.current_app.signature") as sig: + sig.return_value.apply_async.return_value = MagicMock(id="cb") + barrier_pg_decr_and_check( + {"f": 1}, execution_id="exec-FH", callback_descriptor=descriptor + ) + assert sig.call_args.kwargs["headers"] == {FAIRNESS_HEADER_NAME: {"o": 1}} + + def test_callback_dispatch_failure_preserves_row(self, barrier_db): + # The central failure-masking invariant: dispatch happens BEFORE the row + # is deleted, so an apply_async failure leaves the row in place (reclaimed + # by expiry) — guards against a delete-before-dispatch reorder. + _seed(barrier_db, "exec-CF", 1) + with patch("celery.current_app.signature") as sig: + sig.return_value.apply_async.side_effect = RuntimeError("broker down") + with pytest.raises(RuntimeError): + barrier_pg_decr_and_check( + {"f": 1}, execution_id="exec-CF", callback_descriptor=_CALLBACK + ) + assert _row(barrier_db, "exec-CF") is not None # row survives for TTL reclaim + + def test_list_result_appended_as_single_element(self, barrier_db): + # jsonb_build_array() must append a list-shaped result as ONE element + # (plain `||` would concatenate it). Guards that choice. + _seed(barrier_db, "exec-L", 2) + barrier_pg_decr_and_check( + [1, 2], execution_id="exec-L", callback_descriptor=_CALLBACK + ) + remaining, results = _row(barrier_db, "exec-L") + assert remaining == 1 + assert results == [[1, 2]] # one element that is the list, not [1, 2] + + def test_nul_byte_result_tears_down_barrier(self, barrier_db): + # A NUL byte survives json.dumps but jsonb rejects it. The barrier must + # be torn down (fail fast) rather than hang to expiry. + _seed(barrier_db, "exec-NB", 1) + with pytest.raises(psycopg2.DataError): + barrier_pg_decr_and_check( + {"f": "bad\x00value"}, + execution_id="exec-NB", + callback_descriptor=_CALLBACK, + ) + assert _row(barrier_db, "exec-NB") is None # torn down, not left hanging + + def test_decrement_after_abort_does_not_fire(self, barrier_db): + # The new failure-masking model: an aborted barrier is GONE (abort + # deletes the row), so a late in-flight decrement finds no row and never + # fires — even when it would otherwise have hit remaining == 0. + _seed(barrier_db, "exec-DA", 1) + barrier_pg_abort(execution_id="exec-DA") # header failed → row deleted + with patch("celery.current_app.signature") as sig: + out = barrier_pg_decr_and_check( + {"f": 1}, execution_id="exec-DA", callback_descriptor=_CALLBACK + ) + assert out["status"] == "abandoned" + sig.assert_not_called() + + def test_negative_remaining_does_not_fire(self, barrier_db): + _seed(barrier_db, "exec-N", 0) # decrement → -1 + with patch("celery.current_app.signature") as sig: + out = barrier_pg_decr_and_check( + {"f": 1}, execution_id="exec-N", callback_descriptor=_CALLBACK + ) + assert out["status"] == "abandoned" + sig.assert_not_called() + assert _row(barrier_db, "exec-N") is None + + def test_missing_row_does_not_fire(self, barrier_db): + with patch("celery.current_app.signature") as sig: + out = barrier_pg_decr_and_check( + {"f": 1}, execution_id="nope", callback_descriptor=_CALLBACK + ) + assert out["status"] == "abandoned" + sig.assert_not_called() + + def test_unserialisable_result_raises(self, barrier_db): + _seed(barrier_db, "exec-U", 1) + with pytest.raises(TypeError): + barrier_pg_decr_and_check( + {object()}, execution_id="exec-U", callback_descriptor=_CALLBACK + ) + + def test_registered_under_canonical_name(self): + assert barrier_pg_decr_and_check.name == "barrier_pg_decr_and_check" + + +class TestAbort: + def test_claims_and_deletes(self, barrier_db): + _seed(barrier_db, "exec-X", 2) + out = barrier_pg_abort(execution_id="exec-X") + assert out["status"] == "aborted" + assert _row(barrier_db, "exec-X") is None + + def test_concurrent_aborts_deduplicate(self, barrier_db): + _seed(barrier_db, "exec-Y", 2) + first = barrier_pg_abort(execution_id="exec-Y") + second = barrier_pg_abort(execution_id="exec-Y") + assert first["status"] == "aborted" + assert second["status"] == "already_aborted" # row gone / claim lost + + def test_registered_under_canonical_name(self): + assert barrier_pg_abort.name == "barrier_pg_abort" + + + def test_max_retries_zero(self): + # A Celery retry would replay the decrement and corrupt the count. + assert barrier_pg_decr_and_check.max_retries == 0 + assert barrier_pg_abort.max_retries == 0 + + +# --- Layer 3: atomicity through the real task + DB constraint --- + + +class TestDecrementAtomicityThroughTask: + def test_exactly_one_task_fires_the_callback(self, barrier_db): + # Two header-task links decrement a remaining=2 barrier concurrently, + # each through barrier_pg_decr_and_check itself (not raw SQL). The row + # lock serialises them so exactly one returns "complete" and the callback + # is dispatched exactly once — the real single-fire guarantee. + import threading + + _seed(barrier_db, "exec-Z", 2) + statuses: dict[str, str] = {} + conns = [] + + def run(label): + conn = create_pg_connection(env_prefix="TEST_DB_") + conn.autocommit = True + conns.append(conn) + pg_barrier._local.conn = conn # this thread's own connection + try: + out = barrier_pg_decr_and_check( + {"t": label}, execution_id="exec-Z", callback_descriptor=_CALLBACK + ) + statuses[label] = out["status"] + finally: + pg_barrier._local.conn = None + + with patch("celery.current_app.signature") as sig: + sig.return_value.apply_async.return_value = MagicMock(id="cb") + threads = [threading.Thread(target=run, args=(x,)) for x in ("a", "b")] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + assert all(not t.is_alive() for t in threads) + assert sorted(statuses.values()) == ["complete", "pending"] + assert sig.return_value.apply_async.call_count == 1 # single fire + for c in conns: + c.close() + + +class TestDbConstraint: + def test_expires_at_must_exceed_created_at(self, barrier_db): + # The one writer-proof invariant (workers SQL can't import the model). + with pytest.raises(psycopg2.errors.CheckViolation): + with barrier_db.cursor() as cur: + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, remaining, results, created_at, expires_at) " + "VALUES ('bad', 1, '[]'::jsonb, now(), now())" # expires == created + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 52a28fda1384ca329facfbed069d6e8db46b2694 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:14:08 +0530 Subject: [PATCH 10/65] =?UTF-8?q?UN-3553=20[FEAT]=20PG=20Queue=209d=20slic?= =?UTF-8?q?e=201=20=E2=80=94=20leader-election=20lease=20(orchestrator=5Fl?= =?UTF-8?q?ock)=20(#2056)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3553 [FEAT] PG Queue 9d slice 1 — leader-election lease (orchestrator_lock) First slice of 9d (orchestrator/reaper): the singleton-guarantee primitive the reaper loop and a future fair-admission gate hang off. Ships dark — nothing acquires leadership yet, so merging changes no runtime behaviour. Why now: 9d was skipped in the merged spine (9c -> liveness -> priority -> PgBarrier) and is the safety net 9e needs — without a reaper, every at-least-once hang / orphaned barrier bottoms out at the 6h TTL with no recovery. The reaper must run as exactly one instance, so leader election is the foundation. Lease, not advisory lock: leadership is a TTL'd row UPDATE (take it if the leader is free or its lease is stale), not pg_advisory_lock. Session-scoped advisory locks don't survive the transaction-pooled PgBouncer the queue connects through (UN-3533) — a plain UPDATE is one transaction, pooling-safe. All time comparisons are server-side (now()), so candidate clock skew can't split leadership. - backend/pg_queue: PgOrchestratorLock single-row model (id PK, leader, acquired_at) + CheckConstraint(id=1); generated migration 0005 + a reversible RunPython seeding the one free row. Free = empty leader (follows the PgQueueMessage.org_id no-nullable-text convention). - workers/queue_backend/pg_queue/leader_election.py: LeaderLease (try_acquire/renew/release), lease_seconds_from_env() (default 10s, loud-on-misconfig), default_worker_id(). Instance-owned self-recovering connection. - tests: 20 real-PG tests. Load-bearing properties — concurrent try_acquire yields exactly one winner; renew returns False after a stale-lease takeover (the signal that stops a stalled leader). Plus lease-expiry takeover, release-frees-immediately, non-holder no-ops, env validation, single-row constraint. Co-Authored-By: Claude Opus 4.8 * UN-3553 [FEAT] Address review: connection ownership, logging, recovery tests Toolkit + SonarCloud review on #2056: - [High] _owns_conn ownership guard: LeaderLease now mirrors PgQueueClient — an injected connection is never closed/swapped on a transient error (it would otherwise silently re-point an injected TEST_DB_/caller connection at a fresh DB_-env one). _get_conn only recreates an OWNED missing/closed conn. - [Medium] Log the owned-connection discard in _cursor (worker_id + exc type) — a silent rebuild on the reaper singleton correlates with missed renews. - [Medium] Test the recovery machinery: owned-conn recovered on OperationalError, owned-conn recovered when rollback fails, injected-conn never swapped. Plus two documented-invariant gaps — same-holder re-acquire on a fresh lease returns False, and release after a takeover is a no-op. - [Low] release() branches on rowcount — only logs "released" when it really freed the lease; a no-op release logs debug (truthful post-mortems). - [Low] Scope the lease_seconds<=0 guard to the explicit-arg branch (dead on the env path, which already rejects <=0). - [Low] Document the exception-propagation contract (raise == "leadership unknown, stop acting"), relabel the durable Usage example. - [Low] Migration: note the seed row is load-bearing (future reaper-bootstrap should self-heal with INSERT ... ON CONFLICT DO NOTHING). - SonarCloud S117: rename the migration's get_model local to lock_model. 25 leader-election tests pass; makemigrations --check clean. Co-Authored-By: Claude Opus 4.8 * UN-3553 [FEAT] Address Greptile: idempotent worker id + clearer renew log - default_worker_id() is now cached (functools.cache) → idempotent per process, so a caller passing it inline in a retry/restart loop can't drift the worker id out from under renew()/release(). Lazy (first-call), so it's fixed after a fork rather than shared across children. Test asserts idempotency. - renew()'s failure warning now reads "not the current leader (taken over by another candidate, or the lease was never held)" — accurate for the non-holder-renew case too, and fixes the "took over"->"taken over" grammar. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../migrations/0005_pgorchestratorlock.py | 50 +++ backend/pg_queue/models.py | 46 +++ workers/queue_backend/pg_queue/__init__.py | 4 + .../queue_backend/pg_queue/leader_election.py | 263 ++++++++++++++ workers/tests/test_leader_election.py | 321 ++++++++++++++++++ 5 files changed, 684 insertions(+) create mode 100644 backend/pg_queue/migrations/0005_pgorchestratorlock.py create mode 100644 workers/queue_backend/pg_queue/leader_election.py create mode 100644 workers/tests/test_leader_election.py diff --git a/backend/pg_queue/migrations/0005_pgorchestratorlock.py b/backend/pg_queue/migrations/0005_pgorchestratorlock.py new file mode 100644 index 0000000000..f3adf28cbb --- /dev/null +++ b/backend/pg_queue/migrations/0005_pgorchestratorlock.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.1 on 2026-06-15 10:35 + +import django.utils.timezone +from django.db import migrations, models + + +def seed_single_row(apps, schema_editor): + """Seed the one lock row (free: leader=''). + + Leader-election is a pure ``UPDATE ... WHERE id = 1`` — the row must exist + for any candidate to ever win; if it's ever absent, every ``try_acquire`` + silently returns ``False`` forever (permanent leaderlessness, no error). So + the future reaper-bootstrap slice should also self-heal with an idempotent + ``INSERT ... ON CONFLICT (id) DO NOTHING`` on startup. ``get_or_create`` + keeps re-runs / squashes idempotent here. + """ + lock_model = apps.get_model("pg_queue", "PgOrchestratorLock") + lock_model.objects.get_or_create(id=1, defaults={"leader": ""}) + + +def unseed_single_row(apps, schema_editor): + lock_model = apps.get_model("pg_queue", "PgOrchestratorLock") + lock_model.objects.filter(id=1).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0004_pgbarrierstate_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PgOrchestratorLock", + fields=[ + ("id", models.IntegerField(default=1, primary_key=True, serialize=False)), + ("leader", models.TextField(blank=True, default="")), + ("acquired_at", models.DateTimeField(default=django.utils.timezone.now)), + ], + options={ + "db_table": "pg_orchestrator_lock", + }, + ), + migrations.AddConstraint( + model_name="pgorchestratorlock", + constraint=models.CheckConstraint( + check=models.Q(("id", 1)), name="pg_orchestrator_lock_single_row" + ), + ), + migrations.RunPython(seed_single_row, unseed_single_row), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index 0eeb75e635..bb086c7783 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -68,6 +68,52 @@ class Meta: ] +class PgOrchestratorLock(models.Model): + """Single-row leader-election lease for the orchestrator/reaper singleton. + + The orchestrator (admit) and reaper (stuck-task recovery, barrier-orphan + sweep) must run as **exactly one** active instance — multiple would contend + on ``SKIP LOCKED`` and double-act on recovery. HA is leader-elected: one or + more candidate processes race to hold this single row; the holder renews its + lease each cycle, and if it dies a standby takes over once the lease goes + stale. + + **Lease, not advisory lock.** Leadership is a TTL'd ``UPDATE`` (acquire if + ``leader`` is free or ``acquired_at`` is older than the lease window), *not* + ``pg_advisory_lock``. Session-scoped advisory locks do not survive + **transaction-pooled PgBouncer** (the queue's connection path — UN-3533), + since the pooler hands a candidate a different backend per transaction. A + plain ``UPDATE`` is one transaction → pooling-safe. All time comparisons use + the **DB clock** (``now()``), so candidate clock skew can't split leadership. + + **Free = empty ``leader``** (not NULL), following the ``PgQueueMessage.org_id`` + convention — a string field shouldn't carry two "no holder" states (S6553). + ``acquired_at`` is only meaningful while ``leader`` is non-empty. + + Single row enforced by ``CheckConstraint(id = 1)``; the row is seeded by the + migration. Managed=True / generated migration, extension-free, same posture + as the sibling models. + """ + + id = models.IntegerField(primary_key=True, default=1) + # Current leader's worker id; "" means the lease is free (no holder). + leader = models.TextField(blank=True, default="") + # When the current leader last acquired/renewed. Only read while leader != ""; + # a free row's value is immaterial. now() default for ORM .create(); the raw + # leader-election SQL always sets it explicitly. + acquired_at = models.DateTimeField(default=timezone.now) + + class Meta: + db_table = "pg_orchestrator_lock" + constraints = [ + # The whole point: at most one lock row. Any second row is a bug. + models.CheckConstraint( + check=models.Q(id=1), + name="pg_orchestrator_lock_single_row", + ), + ] + + class PgBarrierState(models.Model): """Per-execution fan-in barrier state for ``PgBarrier`` (the Postgres ``WORKER_BARRIER_BACKEND``). diff --git a/workers/queue_backend/pg_queue/__init__.py b/workers/queue_backend/pg_queue/__init__.py index 31f51e253d..7e882df8ce 100644 --- a/workers/queue_backend/pg_queue/__init__.py +++ b/workers/queue_backend/pg_queue/__init__.py @@ -31,12 +31,16 @@ from .client import PgQueueClient, QueueMessage from .connection import create_pg_connection +from .leader_election import LeaderLease, default_worker_id, lease_seconds_from_env from .task_payload import TaskPayload, to_payload __all__ = [ + "LeaderLease", "PgQueueClient", "QueueMessage", "TaskPayload", "create_pg_connection", + "default_worker_id", + "lease_seconds_from_env", "to_payload", ] diff --git a/workers/queue_backend/pg_queue/leader_election.py b/workers/queue_backend/pg_queue/leader_election.py new file mode 100644 index 0000000000..0603d60ff5 --- /dev/null +++ b/workers/queue_backend/pg_queue/leader_election.py @@ -0,0 +1,263 @@ +"""Leader-election lease for the orchestrator/reaper singleton. + +The orchestrator (admit) and reaper (stuck-task recovery, barrier-orphan sweep) +must run as **exactly one** active instance — several would contend on +``SKIP LOCKED`` and double-act on recovery. This module is the HA primitive that +makes that safe: candidate processes race to hold the single +``pg_orchestrator_lock`` row; the holder renews each cycle, and a standby takes +over once the lease goes stale. + +**Lease, not advisory lock.** Leadership is a TTL'd ``UPDATE`` (take it if the +``leader`` is free or its ``acquired_at`` is older than the lease window), *not* +``pg_advisory_lock``. Session-scoped advisory locks do not survive the +transaction-pooled PgBouncer the queue connects through (UN-3533) — the pooler +hands out a different backend per transaction, so a session-held lock would be +silently dropped. A plain ``UPDATE`` is one transaction → pooling-safe. Every +time comparison is server-side (``now()``), so candidate clock skew can't split +leadership. + +**Primitive only.** This module is the lease mechanism — acquiring and driving +the lease is the caller's responsibility (the reaper loop). ``try_acquire`` / +``renew`` / ``release`` return ``bool`` for their *expected* outcomes; an +unexpected DB error propagates (fail-loud, by design for an HA primitive). A +caller must treat a raised exception the same as a ``False`` from ``renew`` — +"leadership state unknown, stop acting" — not assume bool-only. + +Usage: + + lease = LeaderLease(default_worker_id()) + if lease.try_acquire(): + try: + while running: + do_one_cycle() + try: + still_leader = lease.renew() + except Exception: + break # leadership unknown — stop acting + if not still_leader: # lost it (we stalled past the TTL) + break # stop acting immediately + sleep(cycle) + finally: + lease.release() +""" + +from __future__ import annotations + +import contextlib +import functools +import logging +import os +import socket +import uuid +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any + +import psycopg2 + +from .connection import create_pg_connection + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + +logger = logging.getLogger(__name__) + +# Lease window: a leader that hasn't renewed within this many seconds is +# considered dead and a standby may take over. The holder must renew well +# inside this window (the reaper loop renews every cycle). Mirrors the labs +# orchestrator's 10s lease. +_DEFAULT_LEASE_SECONDS = 10 + + +def lease_seconds_from_env() -> int: + """Lease window from ``WORKER_PG_ORCHESTRATOR_LEASE_SECONDS`` (default 10s). + + Read at call time so tests can flip it. Invalid / non-positive values raise, + matching the barrier-TTL posture — a non-positive lease would let every + candidate believe leadership is always stale and act simultaneously, which is + exactly the split-brain this primitive exists to prevent. + """ + raw = os.getenv("WORKER_PG_ORCHESTRATOR_LEASE_SECONDS") + if raw is None: + return _DEFAULT_LEASE_SECONDS + try: + value = int(raw) + except ValueError as exc: + raise ValueError( + f"WORKER_PG_ORCHESTRATOR_LEASE_SECONDS={raw!r} is not an integer. " + f"Unset it to default to {_DEFAULT_LEASE_SECONDS}s." + ) from exc + if value <= 0: + raise ValueError( + f"WORKER_PG_ORCHESTRATOR_LEASE_SECONDS={value} must be a positive " + f"integer. Unset it to default to {_DEFAULT_LEASE_SECONDS}s." + ) + return value + + +@functools.cache +def default_worker_id() -> str: + """The process's candidate id (``host:pid:rand``), fixed on first call. + + ``functools.cache`` makes this idempotent: every call within a process returns the + same id, so ``renew``/``release`` match the row this process wrote even if a + caller passes ``default_worker_id()`` inline in a retry/restart loop rather + than capturing it once. The random suffix disambiguates two candidates that + share a host+pid across container restarts. Lazy (computed on first call, not + at import) so it's fixed after any fork rather than shared across children. + """ + return f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}" + + +class LeaderLease: + """A single candidate's handle on the ``pg_orchestrator_lock`` lease. + + One instance owns one Postgres connection (lazily opened, self-recovering on + a dropped socket / PgBouncer recycle — same posture as ``PgBarrier`` / + ``PgQueueClient``). Construct one per candidate process; in tests, inject a + connection to race two instances. + """ + + def __init__( + self, + worker_id: str, + *, + lease_seconds: int | None = None, + conn: PgConnection | None = None, + ) -> None: + if not worker_id or not worker_id.strip(): + # "" is the free sentinel — a candidate must never identify as "". + raise ValueError("LeaderLease worker_id must be a non-empty string") + self._worker_id = worker_id + if lease_seconds is None: + # lease_seconds_from_env already rejects <= 0. + self._lease_seconds = lease_seconds_from_env() + else: + if lease_seconds <= 0: + raise ValueError("lease_seconds must be a positive integer") + self._lease_seconds = lease_seconds + self._conn = conn + # An injected connection belongs to the caller — never close/recycle it + # (mirrors PgQueueClient). Critical: without this, a transient error + # would silently swap an injected TEST_DB_ / caller connection for a + # fresh DB_-env one, re-pointing the lease at a different database. + self._owns_conn = conn is None + + @property + def worker_id(self) -> str: + return self._worker_id + + def _get_conn(self) -> PgConnection: + # Recreate only an OWNED connection that's missing/closed. An injected + # (caller-owned) connection is never swapped — if it's dead, the next + # statement raises rather than silently re-pointing at the DB_ env. + if self._conn is None or (self._owns_conn and self._conn.closed): + self._conn = create_pg_connection(env_prefix="DB_") + return self._conn + + @contextlib.contextmanager + def _cursor(self) -> Iterator[Any]: + """Yield a cursor; commit on success, roll back + recover on error.""" + conn = self._get_conn() + try: + with conn.cursor() as cur: + yield cur + conn.commit() + except Exception as exc: + # A failed rollback proves the connection is unusable regardless of + # the psycopg2 error subclass (a server-side termination can surface + # as a bare DatabaseError). + conn_dead = isinstance( + exc, (psycopg2.OperationalError, psycopg2.InterfaceError) + ) + try: + conn.rollback() + except Exception: + conn_dead = True + # Discard an unusable connection so the next call reconnects — only + # when we own it (an injected connection is the caller's). A silent + # rebuild on the orchestrator/reaper singleton correlates with missed + # renew cycles, so log it rather than swallowing the transition. + if self._owns_conn and (conn_dead or conn.closed): + logger.warning( + "LeaderLease[%s]: connection error (%s) — discarding the " + "owned connection; the next call will reconnect.", + self._worker_id, + type(exc).__name__, + ) + with contextlib.suppress(Exception): + conn.close() + self._conn = None + raise + + def try_acquire(self) -> bool: + """Take leadership if the lease is free or stale. Returns whether we won. + + Wins iff ``leader`` is empty (free) OR ``acquired_at`` is older than the + lease window (previous holder died). The current holder re-affirming + leadership uses :meth:`renew`, not this — a fresh, held lease returns + ``False`` here by design. + """ + with self._cursor() as cur: + cur.execute( + "UPDATE pg_orchestrator_lock " + " SET leader = %s, acquired_at = now() " + " WHERE id = 1 " + " AND (leader = '' " + " OR acquired_at < now() - make_interval(secs => %s)) " + "RETURNING id", + (self._worker_id, self._lease_seconds), + ) + won = cur.fetchone() is not None + if won: + logger.info("LeaderLease: %s acquired leadership", self._worker_id) + return won + + def renew(self) -> bool: + """Extend our lease. Returns ``False`` if we are no longer the leader. + + A ``False`` is the critical safety signal: it means a standby took over + while we stalled past the lease window. The caller **must stop acting** + immediately — continuing would double-drive recovery against the new + leader. A raised DB error means leadership state is *unknown* and must be + treated the same as ``False`` (stop acting) — see the module docstring. + """ + with self._cursor() as cur: + cur.execute( + "UPDATE pg_orchestrator_lock SET acquired_at = now() " + "WHERE id = 1 AND leader = %s RETURNING id", + (self._worker_id,), + ) + still_leader = cur.fetchone() is not None + if not still_leader: + # Fires both when a held lease was taken over (stale → standby won) + # and when a non-holder calls renew — phrase it for both. + logger.warning( + "LeaderLease: %s renew failed — not the current leader " + "(taken over by another candidate, or the lease was never held)", + self._worker_id, + ) + return still_leader + + def release(self) -> None: + """Free the lease on graceful shutdown so a standby takes over at once. + + Only frees it if we still hold it (``leader = us``); if we already lost + leadership this is a no-op, so a late release can't wipe the new holder. + """ + with self._cursor() as cur: + cur.execute( + "UPDATE pg_orchestrator_lock SET leader = '', acquired_at = now() " + "WHERE id = 1 AND leader = %s", + (self._worker_id,), + ) + freed = cur.rowcount == 1 + if freed: + logger.info("LeaderLease: %s released leadership", self._worker_id) + else: + # We already lost the lease to a standby — don't misreport that this + # process freed it (misleading in the split-brain post-mortem this + # primitive exists to support). + logger.debug( + "LeaderLease: %s release was a no-op (not the current holder)", + self._worker_id, + ) diff --git a/workers/tests/test_leader_election.py b/workers/tests/test_leader_election.py new file mode 100644 index 0000000000..24c0217905 --- /dev/null +++ b/workers/tests/test_leader_election.py @@ -0,0 +1,321 @@ +"""Tests for :class:`queue_backend.pg_queue.leader_election.LeaderLease`. + +Two layers: + +1. **Env / construction** — no DB (``lease_seconds_from_env``, worker-id guard). +2. **Lease semantics** — real Postgres. Each ``LeaderLease`` is given its own + autocommit connection so two instances genuinely race the single + ``pg_orchestrator_lock`` row. Skips if Postgres is unreachable or the + ``pg_orchestrator_lock`` migration is unapplied. + +The two load-bearing correctness properties: +- concurrent ``try_acquire`` on a free lock → **exactly one** winner; +- ``renew`` returns ``False`` after a standby took over a stale lease (the + signal that tells a stalled leader to stop acting). +""" + +from __future__ import annotations + +import contextlib +import os +import threading +from types import SimpleNamespace +from unittest.mock import MagicMock + +import psycopg2 +import pytest +from queue_backend.pg_queue.connection import create_pg_connection +from queue_backend.pg_queue.leader_election import ( + LeaderLease, + default_worker_id, + lease_seconds_from_env, +) + +# --- Layer 1: env + construction (no DB) --- + + +class TestLeaseSecondsEnv: + def test_default_is_ten_seconds(self, monkeypatch): + monkeypatch.delenv("WORKER_PG_ORCHESTRATOR_LEASE_SECONDS", raising=False) + assert lease_seconds_from_env() == 10 + + def test_overridable(self, monkeypatch): + monkeypatch.setenv("WORKER_PG_ORCHESTRATOR_LEASE_SECONDS", "30") + assert lease_seconds_from_env() == 30 + + @pytest.mark.parametrize("bad", ["0", "-5", "abc", "1.5"]) + def test_invalid_raises(self, monkeypatch, bad): + monkeypatch.setenv("WORKER_PG_ORCHESTRATOR_LEASE_SECONDS", bad) + with pytest.raises(ValueError): + lease_seconds_from_env() + + +class TestConstruction: + @pytest.mark.parametrize("bad", ["", " "]) + def test_empty_worker_id_rejected(self, bad): + with pytest.raises(ValueError, match="non-empty"): + LeaderLease(bad, conn=object()) # conn unused — guard fires first + + def test_non_positive_lease_seconds_rejected(self): + with pytest.raises(ValueError): + LeaderLease("w1", lease_seconds=0, conn=object()) + + def test_default_worker_id_shape_and_idempotent(self): + default_worker_id.cache_clear() + wid = default_worker_id() + # host:pid:rand shape, three colon-separated parts. + assert len(wid.split(":")) == 3 + # Idempotent within a process (lru_cache) — repeated calls match, so a + # caller can pass it inline without drifting the worker id. + assert default_worker_id() == wid + default_worker_id.cache_clear() + + +# --- Layer 1b: connection recovery / ownership (mocked, no DB) --- + + +def _mock_conn(*, execute_exc=None, rollback_exc=None, fetch=(1,)): + cur = MagicMock() + if execute_exc is not None: + cur.execute.side_effect = execute_exc + cur.fetchone.return_value = fetch + cur.rowcount = 1 + conn = MagicMock() + conn.closed = 0 + conn.cursor.return_value.__enter__.return_value = cur + conn.cursor.return_value.__exit__.return_value = False + if rollback_exc is not None: + conn.rollback.side_effect = rollback_exc + return conn + + +_FACTORY_PATH = "queue_backend.pg_queue.leader_election.create_pg_connection" + + +class TestConnectionRecovery: + """Owned vs injected recovery branches of ``_cursor`` — the load-bearing + self-recovery the docstrings advertise (mirrors PgQueueClient's suite).""" + + def test_owned_conn_recovered_on_operational_error(self, monkeypatch): + bad = _mock_conn(execute_exc=psycopg2.OperationalError("dead")) + good = _mock_conn(fetch=(1,)) + factory = MagicMock(side_effect=[bad, good]) + monkeypatch.setattr(_FACTORY_PATH, factory) + lease = LeaderLease("w") # owns its connection (conn=None) + with pytest.raises(psycopg2.OperationalError): + lease.try_acquire() + bad.rollback.assert_called_once() + bad.close.assert_called_once() + assert lease._conn is None # dead owned handle dropped + assert lease.try_acquire() is True # next call reconnects to good + assert factory.call_count == 2 + + def test_owned_conn_recovered_when_rollback_fails(self, monkeypatch): + # Non-Operational error whose rollback also fails → still treated dead. + bad = _mock_conn( + execute_exc=RuntimeError("boom"), + rollback_exc=psycopg2.DatabaseError("socket gone"), + ) + monkeypatch.setattr(_FACTORY_PATH, MagicMock(side_effect=[bad])) + lease = LeaderLease("w") + with pytest.raises(RuntimeError): + lease.try_acquire() + bad.close.assert_called_once() + assert lease._conn is None + + def test_injected_conn_never_swapped_on_error(self): + # The High finding: an injected (caller-owned) connection must NOT be + # closed + silently replaced with a DB_-env one on a transient error. + bad = _mock_conn(execute_exc=psycopg2.OperationalError("dead")) + lease = LeaderLease("w", conn=bad) + with pytest.raises(psycopg2.OperationalError): + lease.try_acquire() + bad.rollback.assert_called_once() + bad.close.assert_not_called() + assert lease._conn is bad # caller's connection untouched + + +# --- Layer 2: lease semantics (real Postgres) --- + + +def _new_conn(): + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + conn = create_pg_connection(env_prefix="TEST_DB_") + conn.autocommit = True + return conn + + +@pytest.fixture +def lock_db(): + """Reset the single lock row to free before/after each test. + + Yields a helper connection (for assertions / ageing the lease); each + ``LeaderLease`` under test gets its OWN connection so concurrency is real. + Skips when Postgres is unreachable or the migration is unapplied. + """ + try: + conn = _new_conn() + except psycopg2.OperationalError as exc: + pytest.skip(f"Postgres not reachable: {exc}") + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('pg_orchestrator_lock')") + if cur.fetchone()[0] is None: + conn.close() + pytest.skip( + "pg_orchestrator_lock migration not applied (run backend migrate)" + ) + # Ensure the singleton row exists and is free (the migration seeds it, + # but reset defensively so tests are order-independent). + cur.execute( + "INSERT INTO pg_orchestrator_lock (id, leader, acquired_at) " + "VALUES (1, '', now()) " + "ON CONFLICT (id) DO UPDATE SET leader = '', acquired_at = now()" + ) + extra_conns: list = [] + + def make_lease(worker_id, **kw): + c = _new_conn() + extra_conns.append(c) + return LeaderLease(worker_id, conn=c, **kw) + + yield SimpleNamespace(conn=conn, make_lease=make_lease) + with conn.cursor() as cur: + cur.execute( + "UPDATE pg_orchestrator_lock SET leader = '', acquired_at = now() " + "WHERE id = 1" + ) + for c in extra_conns: + with contextlib.suppress(Exception): + c.close() + conn.close() + + +def _leader(conn) -> str: + with conn.cursor() as cur: + cur.execute("SELECT leader FROM pg_orchestrator_lock WHERE id = 1") + return cur.fetchone()[0] + + +def _age_lease(conn, seconds: int) -> None: + """Push acquired_at into the past so the lease looks stale.""" + with conn.cursor() as cur: + cur.execute( + "UPDATE pg_orchestrator_lock " + "SET acquired_at = now() - make_interval(secs => %s) WHERE id = 1", + (seconds,), + ) + + +class TestLeaderLease: + def test_acquire_on_free_lock_succeeds(self, lock_db): + lease = lock_db.make_lease("w1") + assert lease.try_acquire() is True + assert _leader(lock_db.conn) == "w1" + + def test_second_acquirer_fails_while_fresh(self, lock_db): + a = lock_db.make_lease("a") + b = lock_db.make_lease("b") + assert a.try_acquire() is True + assert b.try_acquire() is False + assert _leader(lock_db.conn) == "a" + + def test_stale_lease_allows_takeover(self, lock_db): + a = lock_db.make_lease("a", lease_seconds=10) + b = lock_db.make_lease("b", lease_seconds=10) + assert a.try_acquire() is True + _age_lease(lock_db.conn, 30) # older than the 10s window + assert b.try_acquire() is True + assert _leader(lock_db.conn) == "b" + + def test_same_holder_reacquire_while_fresh_returns_false(self, lock_db): + # The current holder can't re-acquire a fresh lease (the WHERE rejects + # even the holder until it goes stale) — renew is the holder's path. + a = lock_db.make_lease("a") + assert a.try_acquire() is True + assert a.try_acquire() is False + assert a.renew() is True # ...but renew keeps it + assert _leader(lock_db.conn) == "a" + + def test_renew_keeps_leadership(self, lock_db): + a = lock_db.make_lease("a") + b = lock_db.make_lease("b") + assert a.try_acquire() is True + assert a.renew() is True + assert b.try_acquire() is False # still fresh after renew + + def test_renew_by_non_holder_returns_false(self, lock_db): + a = lock_db.make_lease("a") + b = lock_db.make_lease("b") + assert a.try_acquire() is True + assert b.renew() is False + assert _leader(lock_db.conn) == "a" # unchanged + + def test_renew_after_takeover_returns_false(self, lock_db): + # The critical safety signal: a stalled leader learns it lost the lease. + a = lock_db.make_lease("a", lease_seconds=10) + b = lock_db.make_lease("b", lease_seconds=10) + assert a.try_acquire() is True + _age_lease(lock_db.conn, 30) + assert b.try_acquire() is True # standby takes over + assert a.renew() is False # original must stop acting + + def test_release_frees_immediately(self, lock_db): + a = lock_db.make_lease("a") + b = lock_db.make_lease("b") + assert a.try_acquire() is True + a.release() + assert _leader(lock_db.conn) == "" + assert b.try_acquire() is True # no need to wait out the TTL + + def test_release_by_non_holder_is_noop(self, lock_db): + a = lock_db.make_lease("a") + b = lock_db.make_lease("b") + assert a.try_acquire() is True + b.release() # b doesn't hold it + assert _leader(lock_db.conn) == "a" # a still leader + assert a.renew() is True + + def test_release_after_takeover_is_noop(self, lock_db): + # A stalled leader's late release must not wipe the standby that took + # over ("a late release can't wipe the new holder"). + a = lock_db.make_lease("a", lease_seconds=10) + b = lock_db.make_lease("b", lease_seconds=10) + assert a.try_acquire() is True + _age_lease(lock_db.conn, 30) + assert b.try_acquire() is True # standby took over + a.release() # late release from the deposed leader + assert _leader(lock_db.conn) == "b" # new holder intact + + def test_concurrent_acquire_exactly_one_wins(self, lock_db): + # N candidates, each its own connection, race try_acquire on a free lock. + # The row lock serialises the conditional UPDATE → exactly one RETURNING. + results: dict[str, bool] = {} + + def run(label): + lease = lock_db.make_lease(label) + results[label] = lease.try_acquire() + + labels = [f"c{i}" for i in range(5)] + threads = [threading.Thread(target=run, args=(x,)) for x in labels] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + assert all(not t.is_alive() for t in threads) + assert sum(results.values()) == 1 # exactly one winner + winner = next(label for label, won in results.items() if won) + assert _leader(lock_db.conn) == winner + + +class TestSingleRowConstraint: + def test_second_row_rejected(self, lock_db): + with pytest.raises(psycopg2.errors.CheckViolation): + with lock_db.conn.cursor() as cur: + cur.execute( + "INSERT INTO pg_orchestrator_lock (id, leader, acquired_at) " + "VALUES (2, '', now())" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 649e06bd431dbfb7a542ce552b440bd9fe678cd6 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:10:08 +0530 Subject: [PATCH 11/65] =?UTF-8?q?UN-3554=20[FEAT]=20PG=20Queue=209d=20slic?= =?UTF-8?q?e=202=20=E2=80=94=20reaper=20process=20+=20barrier-orphan=20swe?= =?UTF-8?q?ep=20(#2058)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3554 [FEAT] PG Queue 9d slice 2 — reaper process + barrier-orphan sweep Builds on the leader-election lease (UN-3553): stands up the reaper process — the leader-elected recovery loop — with its first recovery job, the barrier-orphan sweep. Ships dark (launched explicitly, never in the default worker set). - reaper.py: - sweep_expired_barriers(conn): DELETE pg_barrier_state WHERE expires_at < now() RETURNING + loud per-orphan WARNING. The documented PgBarrier backstop — reclaims barriers whose header tasks never all completed; a late in-flight decrement then finds no row and abandons (existing semantics). Execution terminal-status recovery is 9e's job. - PgReaper: leader-elected loop. Each cycle renews (steps down to standby if renew() returns False), else tries to acquire; sweeps ONLY while leader. run() loops with graceful SIGTERM/SIGINT shutdown + lease release on exit. Guard: cycle interval must be shorter than the lease window, or the leader thrashes leadership between renews. - reaper_interval_from_env() (WORKER_PG_REAPER_INTERVAL_SECONDS, default 5s), main(), python -m queue_backend.pg_queue.reaper entrypoint. - leader_election.py: expose lease_seconds property (reaper validates its cycle against it). - test_pg_reaper.py: 15 tests — env/construction guards (interval < lease), leadership gating (sweeps only when leader, steps down on renew-fail, releases on stop), real-PG sweep (reclaims only expired, leaves fresh). Out of scope: run-worker.sh wiring + liveness (followup, like 9c-followup); pipeline recovery (counter reconstruction, per-stage re-enqueue) deferred to 9e where there's a real PG pipeline to test against. Co-Authored-By: Claude Opus 4.8 * UN-3554 [FEAT] Address review: sweep rollback, conn recovery, tick contract Toolkit + SonarCloud review on #2058: - [CRITICAL] sweep_expired_barriers now rolls back on error before re-raising (conn is manual-commit; an un-rolled-back failure left it in an aborted-txn state, poisoning every later cycle → silent self-perpetuating stall). This also clears SonarCloud's C-reliability gate. - [HIGH] On a failed sweep PgReaper discards its OWNED connection so the next tick reconnects — covers a poisoned/dead handle that `.closed` alone misses. - [MEDIUM] renew() raising now sets _is_leader=False before propagating (honours the lease's "raise == stop acting" contract). - [MEDIUM] release() failure on shutdown is logged (with the lease-window note) instead of silently suppressed. - [MEDIUM] signal-handler ValueError is re-raised unless we're off the main thread (don't mislabel an unrelated ValueError). - [MEDIUM/type-design] tick() returns a TickOutcome(was_leader, reclaimed) NamedTuple instead of an overloaded `-1` int sentinel; added an is_leader property; lease param typed against a new LeaderLeaseLike Protocol. - [LOW] Reworded the sweep race comment, the step-down log (same-cycle re-acquire), and the run() self-recovery comment for accuracy. - Tests: +8 — run() swallows a tick error; owned-conn recreated-when-closed; injected-conn never swapped; failed-sweep discards owned conn; sweep SQL contract (no DB); sweep rolls back on error; step-down-then-reacquire; renew-raising steps down. 23 total; drive paths via is_leader, no private-flag poking. Co-Authored-By: Claude Opus 4.8 * UN-3554 [FIX] Use pytest.approx for float-equality asserts (SonarCloud S1244) The two reaper-interval asserts compared float returns with ==; the values are exactly representable so it was harmless, but pytest.approx is the correct idiom and clears the S1244 reliability bugs. Co-Authored-By: Claude Opus 4.8 * UN-3554 [FEAT] Address Greptile: manual-commit Layer-4 fixture, close owned conn - The real-Postgres fixture (barrier_conn) was autocommit, which made sweep_expired_barriers' own commit() a no-op and its rollback unreachable — so Layer 4 tested a different mode than the production reaper (create_pg_connection is manual-commit). Switched the fixture to manual-commit and added explicit commits to the seed/read/cleanup helpers, so the real-DB tests now exercise the sweep's commit (and rollback) in production mode. - run() now closes its OWNED sweep connection on shutdown (an injected one is the caller's). Harmless for the main() process but keeps PgReaper clean if ever embedded / test-driven. 23 tests pass; ruff clean. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/queue_backend/pg_queue/__init__.py | 12 + .../queue_backend/pg_queue/leader_election.py | 4 + workers/queue_backend/pg_queue/reaper.py | 307 ++++++++++++++++ workers/tests/test_pg_reaper.py | 329 ++++++++++++++++++ 4 files changed, 652 insertions(+) create mode 100644 workers/queue_backend/pg_queue/reaper.py create mode 100644 workers/tests/test_pg_reaper.py diff --git a/workers/queue_backend/pg_queue/__init__.py b/workers/queue_backend/pg_queue/__init__.py index 7e882df8ce..88d163ac14 100644 --- a/workers/queue_backend/pg_queue/__init__.py +++ b/workers/queue_backend/pg_queue/__init__.py @@ -32,15 +32,27 @@ from .client import PgQueueClient, QueueMessage from .connection import create_pg_connection from .leader_election import LeaderLease, default_worker_id, lease_seconds_from_env +from .reaper import ( + LeaderLeaseLike, + PgReaper, + TickOutcome, + reaper_interval_from_env, + sweep_expired_barriers, +) from .task_payload import TaskPayload, to_payload __all__ = [ "LeaderLease", + "LeaderLeaseLike", "PgQueueClient", + "PgReaper", "QueueMessage", "TaskPayload", + "TickOutcome", "create_pg_connection", "default_worker_id", "lease_seconds_from_env", + "reaper_interval_from_env", + "sweep_expired_barriers", "to_payload", ] diff --git a/workers/queue_backend/pg_queue/leader_election.py b/workers/queue_backend/pg_queue/leader_election.py index 0603d60ff5..0a1d8415d8 100644 --- a/workers/queue_backend/pg_queue/leader_election.py +++ b/workers/queue_backend/pg_queue/leader_election.py @@ -146,6 +146,10 @@ def __init__( def worker_id(self) -> str: return self._worker_id + @property + def lease_seconds(self) -> int: + return self._lease_seconds + def _get_conn(self) -> PgConnection: # Recreate only an OWNED connection that's missing/closed. An injected # (caller-owned) connection is never swapped — if it's dead, the next diff --git a/workers/queue_backend/pg_queue/reaper.py b/workers/queue_backend/pg_queue/reaper.py new file mode 100644 index 0000000000..64ac120965 --- /dev/null +++ b/workers/queue_backend/pg_queue/reaper.py @@ -0,0 +1,307 @@ +"""Reaper — the leader-elected recovery process for the PG queue. + +A singleton, guarded by :class:`LeaderLease` over ``pg_orchestrator_lock``: only +the elected leader runs recovery work each cycle (several reapers would contend +and double-act). This slice ships the process *harness* (lease-maintenance loop ++ graceful shutdown) plus ONE recovery job — the **barrier-orphan sweep**. + +**Barrier-orphan sweep.** Reclaims ``pg_barrier_state`` rows past their +``expires_at`` — a barrier whose header tasks never all completed (the documented +:class:`PgBarrier` backstop). It ``DELETE``s the orphaned row; by PgBarrier's +existing semantics a late in-flight decrement then finds no row and abandons (no +spurious callback). The owning execution is logged loudly. Marking that +execution *terminal* (ERROR) is recovery that needs the backend and the +pipeline's PG shape — that's 9e, not here; this slice is the storage/orphan +backstop only. + +**Deferred to 9e.** Pipeline recovery (counter reconstruction from +``WorkflowFileExecution``, per-stage re-enqueue of stuck file executions) is +defined against the coupled pipeline running on PG, which doesn't exist yet — so +it lands with 9e, against a real PG pipeline it can be tested on. + +**Lease maintenance.** Each cycle the leader renews; if ``renew()`` returns +``False`` (or raises) it lost / can't confirm the lease and steps down to +standby. A standby tries to acquire each cycle. The cycle interval MUST be +shorter than the lease window, or the leader would lose the lease between +renews — enforced in :meth:`PgReaper.__init__`. + +**Ships dark.** Launched explicitly (``python -m queue_backend.pg_queue.reaper`` +or, later, ``run-worker.sh``); never part of the default worker set. With +``WORKER_BARRIER_BACKEND`` left at ``chord`` (default) there are no +``pg_barrier_state`` rows, so the sweep is a no-op until the PG barrier is used. +""" + +from __future__ import annotations + +import contextlib +import logging +import os +import signal +import threading +import time +from typing import TYPE_CHECKING, NamedTuple, Protocol + +from .connection import create_pg_connection +from .leader_election import LeaderLease, default_worker_id + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + +logger = logging.getLogger(__name__) + +# Cadence: how often the leader renews + runs recovery. Enforced shorter than +# the lease window in PgReaper.__init__. +_DEFAULT_REAPER_INTERVAL_SECONDS = 5.0 + + +class LeaderLeaseLike(Protocol): + """Structural contract :class:`PgReaper` needs from a lease. + + The dependency is structural (the tests substitute a duck-typed fake), so the + param is typed against this Protocol — :class:`LeaderLease` satisfies it, and + a fake conforms without inheritance. Same convention as the ``Barrier`` + Protocol elsewhere in the package. + """ + + @property + def lease_seconds(self) -> int: ... + + @property + def worker_id(self) -> str: ... + + def try_acquire(self) -> bool: ... + + def renew(self) -> bool: ... + + def release(self) -> None: ... + + +class TickOutcome(NamedTuple): + """Result of one :meth:`PgReaper.tick` — keeps "was I leader" and "how much + work" on separate channels (an ``int`` sentinel like ``-1`` is truthy and + conflates the two). + """ + + was_leader: bool + reclaimed: int # 0 when standby + + +def reaper_interval_from_env() -> float: + """Cycle interval from ``WORKER_PG_REAPER_INTERVAL_SECONDS`` (default 5s). + + Read at call time. Invalid / non-positive values raise (loud-on-misconfig, + matching the lease/barrier-TTL posture). + """ + raw = os.getenv("WORKER_PG_REAPER_INTERVAL_SECONDS") + if raw is None: + return _DEFAULT_REAPER_INTERVAL_SECONDS + try: + value = float(raw) + except ValueError as exc: + raise ValueError( + f"WORKER_PG_REAPER_INTERVAL_SECONDS={raw!r} is not a number. " + f"Unset it to default to {_DEFAULT_REAPER_INTERVAL_SECONDS}s." + ) from exc + if value <= 0: + raise ValueError( + f"WORKER_PG_REAPER_INTERVAL_SECONDS={value} must be positive. " + f"Unset it to default to {_DEFAULT_REAPER_INTERVAL_SECONDS}s." + ) + return value + + +def sweep_expired_barriers(conn: PgConnection) -> list[str]: + """Reclaim ``pg_barrier_state`` rows past ``expires_at``. Returns their ids. + + A single atomic ``DELETE … RETURNING``: concurrent sweepers would each + reclaim a disjoint subset (``RETURNING`` reports only the rows *this* + statement deleted), so it stays correct even if leadership gating ever fails — + in practice only the leader calls it. Each reclaimed barrier is logged at + WARNING: an orphaned barrier means an execution's header tasks never all + completed, worth surfacing even though deleting the row is the right backstop. + + ``conn`` runs in manual-commit mode, so on any error we roll back before + re-raising — otherwise the connection is left in an aborted-transaction state + and every later statement on it fails with ``InFailedSqlTransaction``. + """ + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM pg_barrier_state WHERE expires_at < now() " + "RETURNING execution_id" + ) + reclaimed = [row[0] for row in cur.fetchall()] + conn.commit() + except Exception: + with contextlib.suppress(Exception): + conn.rollback() + raise + for execution_id in reclaimed: + logger.warning( + "Reaper: reclaimed orphaned barrier for execution %s — header tasks " + "never all completed before expiry; barrier deleted (no callback " + "fired). Execution terminal-status recovery is 9e's job.", + execution_id, + ) + return reclaimed + + +class PgReaper: + """Leader-elected recovery loop. Only the lease holder runs recovery work.""" + + def __init__( + self, + lease: LeaderLeaseLike, + *, + interval_seconds: float | None = None, + sweep_conn: PgConnection | None = None, + ) -> None: + self._lease = lease + self._interval = ( + interval_seconds + if interval_seconds is not None + else reaper_interval_from_env() + ) + # Load-bearing even though reaper_interval_from_env validates: an + # explicitly-injected interval_seconds<=0 reaches here unvalidated. + if self._interval <= 0: + raise ValueError("interval_seconds must be positive") + if self._interval >= lease.lease_seconds: + # A leader that ticks slower than its lease window loses the lease + # between renews → it would thrash leadership every cycle. + raise ValueError( + f"reaper interval {self._interval}s must be shorter than the " + f"lease window {lease.lease_seconds}s, or the leader loses the " + f"lease between renews" + ) + self._sweep_conn = sweep_conn + self._owns_sweep_conn = sweep_conn is None + self._running = False + self._is_leader = False + + @property + def is_leader(self) -> bool: + """Whether this process currently holds leadership (last tick's view).""" + return self._is_leader + + def _get_sweep_conn(self) -> PgConnection: + # Recreate only an OWNED missing/closed connection; an injected one is the + # caller's and is never swapped (mirrors LeaderLease / PgQueueClient). + if self._sweep_conn is None or ( + self._owns_sweep_conn and self._sweep_conn.closed + ): + self._sweep_conn = create_pg_connection(env_prefix="DB_") + return self._sweep_conn + + def _discard_owned_sweep_conn(self) -> None: + # After a sweep error, drop an owned connection so the next tick + # reconnects — covers a poisoned (aborted-txn) or dead-socket handle that + # `.closed` alone wouldn't catch. + if self._owns_sweep_conn and self._sweep_conn is not None: + with contextlib.suppress(Exception): + self._sweep_conn.close() + self._sweep_conn = None + logger.warning( + "Reaper: discarded sweep connection after a failed sweep; " + "reconnecting next cycle" + ) + + def tick(self) -> TickOutcome: + """One cycle: maintain leadership, then sweep iff leader.""" + if self._is_leader: + try: + still_leader = self._lease.renew() + except Exception: + # A raised renew == "leadership unknown": stop acting (honour the + # lease's documented contract) before letting it propagate. + self._is_leader = False + raise + if not still_leader: + logger.warning( + "Reaper: lost leadership (lease taken over) — stepping down " + "to standby" + ) + self._is_leader = False + if not self._is_leader and self._lease.try_acquire(): + self._is_leader = True + logger.info("Reaper: acquired leadership") + if not self._is_leader: + return TickOutcome(was_leader=False, reclaimed=0) + try: + reclaimed = len(sweep_expired_barriers(self._get_sweep_conn())) + except Exception: + self._discard_owned_sweep_conn() + raise + return TickOutcome(was_leader=True, reclaimed=reclaimed) + + def run(self, *, install_signals: bool = True) -> None: + """Lease-maintenance + recovery loop until stopped; releases on exit.""" + self._running = True + if install_signals: + self._install_signal_handlers() + logger.info( + "Reaper started (interval=%ss, lease=%ss, worker_id=%s)", + self._interval, + self._lease.lease_seconds, + self._lease.worker_id, + ) + try: + while self._running: + try: + self.tick() + except Exception: + # A transient DB blip must not tear the loop down — the lease + # connection rolls back + discards a dead handle, and a failed + # sweep discards its owned connection, so log and keep cycling. + logger.exception("Reaper: cycle failed; continuing") + time.sleep(self._interval) + finally: + # Hand the lease over promptly so a standby takes leadership without + # waiting out the full lease window. + if self._is_leader: + try: + self._lease.release() + except Exception: + logger.warning( + "Reaper: failed to release lease on shutdown; a standby " + "will take over after the lease window (~%ss) instead of " + "immediately", + self._lease.lease_seconds, + exc_info=True, + ) + # Close our owned sweep connection (an injected one is the caller's). + # Harmless for the main() process — the OS reclaims it — but keeps the + # class clean if it's ever embedded / driven from a test. + if self._owns_sweep_conn and self._sweep_conn is not None: + with contextlib.suppress(Exception): + self._sweep_conn.close() + self._sweep_conn = None + logger.info("Reaper stopped") + + def stop(self, *_: object) -> None: + """Request a graceful stop after the current cycle.""" + self._running = False + + def _install_signal_handlers(self) -> None: + try: + signal.signal(signal.SIGTERM, self.stop) + signal.signal(signal.SIGINT, self.stop) + except ValueError: + # signal.signal raises ValueError off the main thread — assert that + # cause rather than mislabelling an unrelated ValueError. + if threading.current_thread() is threading.main_thread(): + raise + logger.warning( + "Reaper: signal handlers not installed (non-main thread) — " + "SIGTERM/SIGINT will not trigger graceful shutdown" + ) + + +def main() -> None: + logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) + lease = LeaderLease(default_worker_id()) + PgReaper(lease).run() + + +if __name__ == "__main__": + main() diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py new file mode 100644 index 0000000000..b9fe9f5984 --- /dev/null +++ b/workers/tests/test_pg_reaper.py @@ -0,0 +1,329 @@ +"""Tests for the PG-queue reaper (:mod:`queue_backend.pg_queue.reaper`). + +Layers: + +1. **Env / construction** — no DB (``reaper_interval_from_env``, the + interval-shorter-than-lease guard). +2. **Leadership gating** — a fake lease + a patched sweep, no DB: recovery runs + only while leader; a lost lease steps the reaper down; it re-acquires a later + cycle; the lease is released on shutdown; ``run`` swallows a tick error. +3. **Connection handling** — mocked: a failed sweep rolls back + discards the + owned connection; an injected connection is never swapped; the SQL contract. +4. **Barrier-orphan sweep** — real Postgres: only rows past ``expires_at`` are + reclaimed; fresh barriers are left intact. +""" + +from __future__ import annotations + +import os +import threading +import time +from unittest.mock import MagicMock, patch + +import psycopg2 +import pytest +from queue_backend.pg_queue import reaper as reaper_mod +from queue_backend.pg_queue.connection import create_pg_connection +from queue_backend.pg_queue.reaper import ( + PgReaper, + reaper_interval_from_env, + sweep_expired_barriers, +) + +# --- Layer 1: env + construction (no DB) --- + + +class TestIntervalEnv: + def test_default_is_five_seconds(self, monkeypatch): + monkeypatch.delenv("WORKER_PG_REAPER_INTERVAL_SECONDS", raising=False) + assert reaper_interval_from_env() == pytest.approx(5.0) + + def test_overridable(self, monkeypatch): + monkeypatch.setenv("WORKER_PG_REAPER_INTERVAL_SECONDS", "2.5") + assert reaper_interval_from_env() == pytest.approx(2.5) + + @pytest.mark.parametrize("bad", ["0", "-1", "abc"]) + def test_invalid_raises(self, monkeypatch, bad): + monkeypatch.setenv("WORKER_PG_REAPER_INTERVAL_SECONDS", bad) + with pytest.raises(ValueError): + reaper_interval_from_env() + + +class _FakeLease: + """Duck-typed LeaderLease. ``acquires``/``renews`` accept a bool (constant) + or a list (one outcome popped per call, then ``False``). + """ + + def __init__(self, *, acquires=True, renews=True, lease_seconds=10): + self._acquires = acquires + self._renews = renews + self.lease_seconds = lease_seconds + self.worker_id = "fake" + self.released = False + self.acquire_calls = 0 + self.renew_calls = 0 + + @staticmethod + def _next(val): + if isinstance(val, list): + return val.pop(0) if val else False + return val + + def try_acquire(self): + self.acquire_calls += 1 + return self._next(self._acquires) + + def renew(self): + self.renew_calls += 1 + return self._next(self._renews) + + def release(self): + self.released = True + + +class TestConstruction: + def test_interval_must_be_shorter_than_lease(self): + with pytest.raises(ValueError, match="shorter than the lease"): + PgReaper(_FakeLease(lease_seconds=5), interval_seconds=5, sweep_conn=object()) + + def test_non_positive_interval_rejected(self): + with pytest.raises(ValueError): + PgReaper(_FakeLease(), interval_seconds=0, sweep_conn=object()) + + def test_valid_interval_accepted(self): + PgReaper(_FakeLease(lease_seconds=10), interval_seconds=3, sweep_conn=object()) + + +# --- Layer 2: leadership gating (fake lease + patched sweep, no DB) --- + + +class TestLeadershipGating: + def _reaper(self, lease): + return PgReaper(lease, interval_seconds=0.01, sweep_conn=object()) + + def test_sweeps_when_leader(self): + reaper = self._reaper(_FakeLease(acquires=True, renews=True)) + with patch.object( + reaper_mod, "sweep_expired_barriers", return_value=["x"] + ) as sweep: + outcome = reaper.tick() # acquires leadership → sweeps + assert outcome.was_leader is True + assert outcome.reclaimed == 1 + assert reaper.is_leader is True + sweep.assert_called_once() + + def test_standby_does_not_sweep(self): + reaper = self._reaper(_FakeLease(acquires=False)) # can't get the lease + with patch.object(reaper_mod, "sweep_expired_barriers") as sweep: + outcome = reaper.tick() + assert outcome == (False, 0) + assert reaper.is_leader is False + sweep.assert_not_called() + + def test_steps_down_when_renew_fails(self): + # tick 1 acquires; tick 2 renew fails → step down, acquire also fails → + # standby. Driven through ticks, no private-flag poking. + reaper = self._reaper(_FakeLease(acquires=[True, False], renews=[False])) + with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + assert reaper.tick().was_leader is True + assert reaper.tick().was_leader is False + assert reaper.is_leader is False + + def test_steps_down_then_reacquires(self): + # leader → lose the lease one cycle → re-acquire the next and resume. + reaper = self._reaper(_FakeLease(acquires=[True, False, True], renews=[False])) + with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + assert reaper.tick().was_leader is True # acquired + assert reaper.tick().was_leader is False # renew failed → standby + assert reaper.tick().was_leader is True # re-acquired + assert reaper.is_leader is True + + def test_renew_raising_steps_down(self): + lease = _FakeLease(acquires=True, renews=True) + reaper = self._reaper(lease) + with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + reaper.tick() # becomes leader + lease.renew = MagicMock(side_effect=psycopg2.OperationalError("boom")) + with pytest.raises(psycopg2.OperationalError): + reaper.tick() + assert reaper.is_leader is False # raised renew == stop acting + + def test_release_on_stop_when_leader(self): + lease = _FakeLease(acquires=True, renews=True) + reaper = self._reaper(lease) + with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + t = threading.Thread(target=reaper.run, kwargs={"install_signals": False}) + t.start() + time.sleep(0.05) + reaper.stop() + t.join(timeout=5) + assert not t.is_alive() + assert lease.released is True + + def test_run_swallows_tick_exception(self): + reaper = PgReaper(_FakeLease(), interval_seconds=0.01, sweep_conn=object()) + calls = {"n": 0} + + def boom(): + calls["n"] += 1 + reaper.stop() # one cycle only + raise RuntimeError("transient blip") + + with patch.object(reaper, "tick", side_effect=boom): + with patch.object(reaper_mod.logger, "exception") as logexc: + reaper.run(install_signals=False) # must not propagate + assert calls["n"] == 1 + logexc.assert_called_once() + + +# --- Layer 3: connection handling (mocked, no DB) --- + + +class TestSweepConnection: + def test_sql_contract(self): + cur = MagicMock() + cur.fetchall.return_value = [("e1",), ("e2",)] + conn = MagicMock() + conn.cursor.return_value.__enter__.return_value = cur + assert sweep_expired_barriers(conn) == ["e1", "e2"] + sql = cur.execute.call_args[0][0] + assert "DELETE FROM pg_barrier_state" in sql + assert "expires_at < now()" in sql + assert "RETURNING execution_id" in sql + conn.commit.assert_called_once() + + def test_rolls_back_on_error(self): + cur = MagicMock() + cur.execute.side_effect = psycopg2.OperationalError("dead") + conn = MagicMock() + conn.cursor.return_value.__enter__.return_value = cur + with pytest.raises(psycopg2.OperationalError): + sweep_expired_barriers(conn) + conn.rollback.assert_called_once() + conn.commit.assert_not_called() + + def test_owned_conn_recreated_when_closed(self, monkeypatch): + dead = MagicMock(closed=True) + fresh = MagicMock(closed=False) + factory = MagicMock(side_effect=[dead, fresh]) + monkeypatch.setattr(reaper_mod, "create_pg_connection", factory) + reaper = PgReaper(_FakeLease(), interval_seconds=1) # owns its conn + assert reaper._get_sweep_conn() is dead + assert reaper._get_sweep_conn() is fresh # dead.closed → recreate + assert factory.call_count == 2 + + def test_injected_conn_never_swapped(self): + injected = MagicMock(closed=True) # even closed, it's the caller's + reaper = PgReaper(_FakeLease(), interval_seconds=1, sweep_conn=injected) + assert reaper._get_sweep_conn() is injected + + def test_failed_sweep_discards_owned_conn(self, monkeypatch): + conn = MagicMock(closed=False) + monkeypatch.setattr( + reaper_mod, "create_pg_connection", MagicMock(return_value=conn) + ) + reaper = PgReaper(_FakeLease(acquires=True, renews=True), interval_seconds=1) + with patch.object( + reaper_mod, + "sweep_expired_barriers", + side_effect=psycopg2.OperationalError("x"), + ): + with pytest.raises(psycopg2.OperationalError): + reaper.tick() + conn.close.assert_called_once() + assert reaper._sweep_conn is None # next tick reconnects + + +# --- Layer 4: barrier-orphan sweep (real Postgres) --- + + +def _new_conn(): + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + # Manual-commit — exactly as the production reaper opens it + # (create_pg_connection default). NOT autocommit: that would make + # sweep_expired_barriers' own commit() a no-op and its rollback unreachable, + # so Layer 4 would test a different mode than the real reaper runs in. + return create_pg_connection(env_prefix="TEST_DB_") + + +@pytest.fixture +def barrier_conn(): + try: + conn = _new_conn() + except psycopg2.OperationalError as exc: + pytest.skip(f"Postgres not reachable: {exc}") + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('pg_barrier_state')") + if cur.fetchone()[0] is None: + conn.close() + pytest.skip("pg_barrier_state migration not applied (run backend migrate)") + cur.execute("DELETE FROM pg_barrier_state") + conn.commit() + yield conn + with conn.cursor() as cur: + cur.execute("DELETE FROM pg_barrier_state") + conn.commit() + conn.close() + + +def _seed(conn, execution_id, *, expired): + # created_at must precede expires_at (CheckConstraint + # pg_barrier_expires_after_created). Commit so the seed is durable like a + # real barrier row (written by PgBarrier in another transaction) — and so the + # manual-commit sweep's own commit() is what persists the DELETE. + with conn.cursor() as cur: + if expired: + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, remaining, results, created_at, expires_at) " + "VALUES (%s, 1, '[]'::jsonb, now() - interval '2 hours', " + " now() - interval '1 hour')", + (execution_id,), + ) + else: + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, remaining, results, created_at, expires_at) " + "VALUES (%s, 1, '[]'::jsonb, now(), now() + interval '6 hours')", + (execution_id,), + ) + conn.commit() + + +def _ids(conn): + with conn.cursor() as cur: + cur.execute("SELECT execution_id FROM pg_barrier_state ORDER BY execution_id") + rows = [r[0] for r in cur.fetchall()] + conn.commit() # end the read transaction (manual-commit conn) + return rows + + +class TestSweepExpiredBarriers: + def test_reclaims_only_expired(self, barrier_conn): + _seed(barrier_conn, "exp-1", expired=True) + _seed(barrier_conn, "exp-2", expired=True) + _seed(barrier_conn, "fresh-1", expired=False) + reclaimed = sweep_expired_barriers(barrier_conn) + assert sorted(reclaimed) == ["exp-1", "exp-2"] + assert _ids(barrier_conn) == ["fresh-1"] # fresh barrier untouched + + def test_noop_when_nothing_expired(self, barrier_conn): + _seed(barrier_conn, "fresh-1", expired=False) + assert sweep_expired_barriers(barrier_conn) == [] + assert _ids(barrier_conn) == ["fresh-1"] + + def test_tick_sweeps_via_real_conn(self, barrier_conn): + _seed(barrier_conn, "exp-1", expired=True) + reaper = PgReaper( + _FakeLease(acquires=True, renews=True), + interval_seconds=1, + sweep_conn=barrier_conn, + ) + outcome = reaper.tick() # became leader and reclaimed the orphan + assert outcome.was_leader is True + assert outcome.reclaimed == 1 + assert _ids(barrier_conn) == [] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 35c735f315e3e44b72ef2e1eaad1e9ed2fec976e Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:51:06 +0530 Subject: [PATCH 12/65] =?UTF-8?q?UN-3555=20[FEAT]=20PG=20Queue=209d=20slic?= =?UTF-8?q?e-2=20followup=20=E2=80=94=20run-worker.sh=20reaper=20type=20+?= =?UTF-8?q?=20pg-queue=20set=20(#2059)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3555 [FEAT] PG Queue 9d slice-2 followup — run-worker.sh reaper type + pg-queue set Wires the reaper (UN-3554) into run-worker.sh and adds a pg-queue set so the whole PG-queue group launches in one shot. Mirrors the 9c -> 9c-followup split (launcher wiring as its own slice). Liveness probe is a separate follow-on. - workers/pg_queue_reaper/: thin entrypoint package (python -m pg_queue_reaper -> queue_backend.pg_queue.reaper.main). No worker-app bootstrap (the reaper runs no Celery tasks), unlike pg_queue_consumer; exists so the process has a stable name run-worker.sh can launch + pgrep-match. - run-worker.sh: - reaper / pg-queue-reaper type — opt-in (NOT in `all`), launches `python -m pg_queue_reaper`, runs from workers root, --status/-k/-r match via the `-m` pgrep branch (now covers consumer + reaper). Lease/interval env documented in --help. - pg / pg-queue SET — run_pg_queue_set launches consumer + reaper together (always detached, like `all`). Restart (-r pg-queue) kills both members then relaunches. list_core_worker_dirs skips the set alias (no phantom status entry). Help documents the Celery `all` set and the PG `pg-queue` set as independent, runnable in parallel for a dual-transport (strangler-fig) setup. Dev-tested live: `run-worker.sh reaper -d` acquires leadership + ticks and shows RUNNING in --status; `run-worker.sh pg-queue` brings up consumer + reaper, both RUNNING, reaper leader, no phantom set entry; bash -n clean; --help renders. Co-Authored-By: Claude Opus 4.8 * UN-3555 [FEAT] Address review: set start-failure propagation, restart-kill guard Toolkit review on #2059: - [High] run_pg_queue_set swallowed member start-failures (backgrounded subshells' status was lost, banner+return 0 unconditional). Now each member runs in a FOREGROUND subshell so run_worker's own `return 1` on a crash-on-start is captured; the set returns non-zero if any member fails. (The reviewer's kill -0 on `$!` would false-fail — that PID is the launcher subshell, which exits the instant it backgrounds the nohup'd worker; the foreground-subshell return value is the correct signal and isolates `cd`.) Documented that the set always runs detached (ignores -d). - [Medium] Dispatch now `|| exit 1` so a member start-failure reaches the script exit code — the only programmatic startup signal (reaper has no health port yet). - [Medium] Set-restart aggregates kill_one_worker failures and aborts the relaunch if a member survives SIGKILL (avoids a duplicate consumer double-polling Postgres). Mirrors kill_workers' discipline. - [Medium/minor] Startup banner prints `Queues: n/a` when empty (reaper). - [Low] Reworded pg_queue_reaper/__main__ docstring: the launcher DOES export WORKER_TYPE for every worker; the accurate claim is the reaper neither reads nor mutates it (vs the consumer overwriting it before `import worker`). - [Low] Added a smoke test that pg_queue_reaper.__main__ re-exports the real reaper main (guards the `python -m pg_queue_reaper` launch path against an ImportError regression). Dev-tested: `run-worker.sh pg-queue` returns exit 0 with both members up; 24 reaper tests pass; bash -n + ruff clean. Co-Authored-By: Claude Opus 4.8 * UN-3555 [FEAT] Address Greptile: set partial-start teardown + --logs set alias - run_pg_queue_set: on a partial start-failure (one member up, the other crashed) tear the whole set down before returning 1 — kill both members so a restart-on-failure relaunch can't spawn a second instance over the survivor (the consumer would double-poll Postgres). All-or-nothing, mirroring the restart path's discipline. - tail_logs: handle the pg/pg-queue set alias — `--logs pg-queue` now tails both member logs (pg_queue_consumer + pg_queue_reaper) instead of looking for a non-existent workers/pg-queue/pg-queue.log and printing a misleading "no log file" error. Mirrors how list_core_worker_dirs skips the set value. Dev-tested: `--logs pg-queue` tails 2 files (consumer + reaper); bash -n clean; 24 reaper tests pass. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/pg_queue_reaper/__init__.py | 7 ++ workers/pg_queue_reaper/__main__.py | 18 ++++ workers/run-worker.sh | 131 ++++++++++++++++++++++++---- workers/tests/test_pg_reaper.py | 16 ++++ 4 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 workers/pg_queue_reaper/__init__.py create mode 100644 workers/pg_queue_reaper/__main__.py diff --git a/workers/pg_queue_reaper/__init__.py b/workers/pg_queue_reaper/__init__.py new file mode 100644 index 0000000000..ed931d2681 --- /dev/null +++ b/workers/pg_queue_reaper/__init__.py @@ -0,0 +1,7 @@ +"""Launcher package for the PG-queue reaper process. + +A thin top-level package so the reaper has a stable ``python -m pg_queue_reaper`` +invocation that ``run-worker.sh`` can launch and pgrep-match — mirroring +``pg_queue_consumer``. The actual loop lives in +``queue_backend.pg_queue.reaper``. +""" diff --git a/workers/pg_queue_reaper/__main__.py b/workers/pg_queue_reaper/__main__.py new file mode 100644 index 0000000000..6a7dc58c55 --- /dev/null +++ b/workers/pg_queue_reaper/__main__.py @@ -0,0 +1,18 @@ +"""Entry point: run the PG-queue reaper (leader-elected recovery loop). + +Unlike the consumer, the reaper runs no Celery tasks — it only does SQL recovery +(the barrier-orphan sweep) — so it needs **no** worker-app bootstrap: it never +``import``s ``worker``. (``run-worker.sh`` still exports ``WORKER_TYPE`` for every +worker, reaper included, but — unlike the consumer, which overwrites it before +``import worker`` to select which tasks to register — the reaper neither reads +nor mutates it.) This package exists purely so the process has a stable +``python -m pg_queue_reaper`` name that ``run-worker.sh`` can launch and match, +parallel to ``pg_queue_consumer``. + +Launch via ``python -m pg_queue_reaper`` or ``./run-worker.sh reaper``. +""" + +from queue_backend.pg_queue.reaper import main + +if __name__ == "__main__": + main() diff --git a/workers/run-worker.sh b/workers/run-worker.sh index 01875712e3..46c4b941fb 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -27,6 +27,10 @@ readonly IDE_CALLBACK_WORKER_TYPE="ide_callback" # Canonical name of the PG-queue consumer worker (referenced in several maps # and special-cases below; a constant keeps them in sync). readonly PG_QUEUE_CONSUMER_TYPE="pg_queue_consumer" +# Canonical name of the PG-queue reaper (leader-elected recovery process). +readonly PG_QUEUE_REAPER_TYPE="pg_queue_reaper" +# Set alias that launches the whole PG-queue group (consumer + reaper) together. +readonly PG_QUEUE_SET="pg-queue" # Available workers declare -A WORKERS=( @@ -51,6 +55,13 @@ declare -A WORKERS=( ["pg-queue-consumer"]="$PG_QUEUE_CONSUMER_TYPE" ["$PG_QUEUE_CONSUMER_TYPE"]="$PG_QUEUE_CONSUMER_TYPE" ["pg-consumer"]="$PG_QUEUE_CONSUMER_TYPE" + # PG Queue reaper — leader-elected recovery loop (barrier-orphan sweep) + ["reaper"]="$PG_QUEUE_REAPER_TYPE" + ["pg-queue-reaper"]="$PG_QUEUE_REAPER_TYPE" + ["$PG_QUEUE_REAPER_TYPE"]="$PG_QUEUE_REAPER_TYPE" + # Set: launch the whole PG-queue group (consumer + reaper) in one shot + ["$PG_QUEUE_SET"]="$PG_QUEUE_SET" + ["pg"]="$PG_QUEUE_SET" ["all"]="all" ) @@ -98,6 +109,7 @@ declare -A WORKER_HEALTH_PORTS=( # failure (they'd otherwise show STOPPED after every `all`). declare -A OPTIN_WORKERS=( ["$PG_QUEUE_CONSUMER_TYPE"]=1 + ["$PG_QUEUE_REAPER_TYPE"]=1 ) # Function to display usage @@ -118,12 +130,18 @@ WORKER_TYPE: executor Run executor worker (extraction execution tasks) ide-callback Run IDE callback worker (Prompt Studio post-execution callbacks) pg-queue-consumer Run PG-queue poll-loop consumer (opt-in; not part of 'all') + reaper, pg-queue-reaper Run PG-queue reaper (leader-elected recovery; opt-in) + pg, pg-queue Run the PG-queue set (consumer + reaper) together all Run all workers (in separate processes, includes auto-discovered pluggable workers) Note: Pluggable workers in pluggable_worker/ directory are automatically discovered and can be run by name. +Note: 'all' is the Celery worker set; 'pg-queue' is the PG-queue set. They are independent — + run both in parallel for a dual-transport (strangler-fig) setup. Note: pg-queue-consumer overrides: WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE (source worker whose tasks to load, default notification), WORKER_PG_QUEUE_CONSUMER_QUEUE (queue to poll), and WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT (liveness server port, default 8090). +Note: reaper overrides: WORKER_PG_ORCHESTRATOR_LEASE_SECONDS (lease window, default 10), + WORKER_PG_REAPER_INTERVAL_SECONDS (cycle interval, default 5). OPTIONS: -e, --env-file FILE Use specific environment file (default: .env) @@ -331,11 +349,11 @@ validate_env() { # --hostname=callback-worker-${id}@%h (when WORKER_INSTANCE_ID is set) get_worker_pids() { local worker_type=$1 pattern out rc - # The PG-queue consumer runs as `python -m pg_queue_consumer`, not a Celery - # `-worker@host` process, so it has no `-worker` token to anchor on. - # Match its module invocation instead (covers both the `uv run python` - # parent and the `python -m` child). Keeps --status / -k / -r working for it. - if [[ "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" ]]; then + # The PG-queue consumer and reaper run as `python -m `, not a Celery + # `-worker@host` process, so they have no `-worker` token to anchor on. + # Match the module invocation instead (covers both the `uv run python` parent + # and the `python -m` child). Keeps --status / -k / -r working for them. + if [[ "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" || "$worker_type" == "$PG_QUEUE_REAPER_TYPE" ]]; then pattern="-m[[:space:]]+${worker_type}([[:space:]]|\$)" else pattern="[^[:alnum:]_]${worker_type}-worker(@|-)" @@ -366,7 +384,9 @@ list_core_worker_dirs() { local seen="" for key in "${!WORKERS[@]}"; do local value="${WORKERS[$key]}" - if [[ "$value" == "all" ]]; then + # Skip set aliases ("all", "pg-queue") — they're groups, not real worker + # dirs/processes, so status must not list them as phantom STOPPED workers. + if [[ "$value" == "all" || "$value" == "$PG_QUEUE_SET" ]]; then continue fi if [[ "$seen" == *" $value "* ]]; then @@ -419,13 +439,22 @@ tail_logs() { print_status $BLUE "Tip: omit the worker type to tail all logs" exit 1 fi - local f - f=$(resolve_log_file "$canonical") - if [[ -z "$f" ]]; then - print_status $YELLOW "No log file found for $canonical. Did you start it with -d?" - exit 0 + if [[ "$canonical" == "$PG_QUEUE_SET" ]]; then + # The set alias maps to no single dir — tail both member logs. + for d in "$PG_QUEUE_CONSUMER_TYPE" "$PG_QUEUE_REAPER_TYPE"; do + local member_log + member_log=$(resolve_log_file "$d") + [[ -n "$member_log" ]] && log_files+=("$member_log") + done + else + local f + f=$(resolve_log_file "$canonical") + if [[ -z "$f" ]]; then + print_status $YELLOW "No log file found for $canonical. Did you start it with -d?" + exit 0 + fi + log_files+=("$f") fi - log_files+=("$f") fi if [[ ${#log_files[@]} -eq 0 ]]; then @@ -717,10 +746,18 @@ run_worker() { cmd_args=("uv" "run" "python" "-m" "$PG_QUEUE_CONSUMER_TYPE") fi + # PG queue reaper — a leader-elected SQL recovery loop (no Celery, no task + # bootstrap). Override the celery command with the plain `python -m` entry. + # Tunables (lease window, cycle interval) come from env; no liveness port is + # wired yet (that's a follow-on slice), so it binds nothing. + if [[ "$worker_type" == "$PG_QUEUE_REAPER_TYPE" ]]; then + cmd_args=("uv" "run" "python" "-m" "$PG_QUEUE_REAPER_TYPE") + fi + print_status $GREEN "Starting $worker_type worker..." print_status $BLUE "Directory: $worker_dir" print_status $BLUE "Worker Name: $worker_instance_name" - print_status $BLUE "Queues: $queues" + print_status $BLUE "Queues: ${queues:-n/a}" # Show the effective port: a -p/--health-port override wins over the map. print_status $BLUE "Health Port: ${health_port:-${WORKER_HEALTH_PORTS[$worker_type]:-n/a}}" print_status $BLUE "Command: ${cmd_args[*]}" @@ -728,9 +765,9 @@ run_worker() { # Change to appropriate directory # For pluggable workers, stay at workers root to allow module imports # For core workers, change to worker directory - if [[ -n "${PLUGGABLE_WORKERS[$worker_type]:-}" || "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" ]]; then - # Run from the workers root so `python -m pg_queue_consumer` (and the - # `worker` app it bootstraps) resolve. + if [[ -n "${PLUGGABLE_WORKERS[$worker_type]:-}" || "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" || "$worker_type" == "$PG_QUEUE_REAPER_TYPE" ]]; then + # Run from the workers root so `python -m pg_queue_consumer` / + # `python -m pg_queue_reaper` (and what they import) resolve. cd "$WORKERS_DIR" else cd "$worker_dir" @@ -826,6 +863,47 @@ run_all_workers() { fi } +# Function to run the PG-queue worker set (consumer + reaper) together. +# A set is multiple processes, so — like 'all' — it ALWAYS runs detached +# (it ignores -d/--detach; there's no foreground form). The PG-queue counterpart +# to 'all' (the Celery set): run both for a dual-transport (strangler-fig) setup. +# The reaper is a leader-elected singleton, so running it on several hosts is +# safe (only one wins the lease). Returns non-zero if any member dies on start — +# the reaper has no health port yet, so this launch check is the only +# programmatic startup signal a caller (systemd/CI) gets. +run_pg_queue_set() { + local log_level=$1 + local concurrency=$2 + local pool_type=$3 + + print_status $GREEN "Starting PG-queue set (consumer + reaper)..." + local failed=0 + for worker in "$PG_QUEUE_CONSUMER_TYPE" "$PG_QUEUE_REAPER_TYPE"; do + print_status $BLUE "Starting $worker in background..." + # A FOREGROUND subshell: run_worker (detach=true) nohup-backgrounds the + # actual worker and returns 1 on an immediate crash-on-start — so the + # subshell's exit status IS that signal. The subshell isolates run_worker's + # `cd` from this loop; the nohup'd worker survives the subshell exiting. + # (A background `( … ) &` would lose the status — its `$!` is the launcher + # subshell, which exits the instant it backgrounds the worker.) + ( run_worker "$worker" "true" "$log_level" "$concurrency" "" "" "$pool_type" "" ) \ + || failed=1 + done + if [[ $failed -ne 0 ]]; then + print_status $RED "PG-queue set: a member failed to start — tearing down the set (see logs above)" + # Don't leave a survivor: a restart-on-failure relaunch would spawn a + # second instance on top of it (the consumer would double-poll Postgres). + # Kill both members (the crashed one is already gone → no-op). Same + # all-or-nothing discipline as the restart path. + kill_one_worker "$PG_QUEUE_CONSUMER_TYPE" + kill_one_worker "$PG_QUEUE_REAPER_TYPE" + show_status + return 1 + fi + print_status $GREEN "PG-queue set started in background" + show_status +} + # Parse command line arguments DETACH=false LOG_LEVEL="" @@ -940,7 +1018,21 @@ fi # WORKER_TYPE as "all" once we set it below. if [[ "$RESTART_MODE" == "true" ]]; then discover_pluggable_workers - if [[ -n "$WORKER_TYPE" && "$WORKER_TYPE" != "all" ]]; then + if [[ "${WORKERS[$WORKER_TYPE]:-}" == "$PG_QUEUE_SET" ]]; then + # Restart the PG-queue set: kill both members, then fall through to the + # launch path (which runs run_pg_queue_set since WORKER_TYPE is the set). + # Aggregate kill failures (kill_one_worker returns 1 if a process survives + # SIGKILL) and abort rather than relaunch over a survivor — a second + # consumer would double-poll Postgres. Mirrors kill_workers' discipline. + print_status $BLUE "Restarting PG-queue set..." + restart_failed=0 + kill_one_worker "$PG_QUEUE_CONSUMER_TYPE" || restart_failed=1 + kill_one_worker "$PG_QUEUE_REAPER_TYPE" || restart_failed=1 + if [[ $restart_failed -ne 0 ]]; then + print_status $RED "Cannot restart PG-queue set: a member survived SIGKILL; aborting to avoid duplicate processes" + exit 1 + fi + elif [[ -n "$WORKER_TYPE" && "$WORKER_TYPE" != "all" ]]; then restart_target_dir="${WORKERS[$WORKER_TYPE]:-${PLUGGABLE_WORKERS[$WORKER_TYPE]:-}}" if [[ -z "$restart_target_dir" ]]; then print_status $RED "Error: Unknown worker type for restart: $WORKER_TYPE" @@ -994,6 +1086,11 @@ export PYTHONPATH="$WORKERS_DIR:${PYTHONPATH:-}" # Run the requested worker(s) if [[ "$WORKER_TYPE" == "all" ]]; then run_all_workers "$DETACH" "$LOG_LEVEL" "$CONCURRENCY" "$POOL_TYPE" +elif [[ "${WORKERS[$WORKER_TYPE]:-}" == "$PG_QUEUE_SET" ]]; then + # The PG-queue set (consumer + reaper). Always backgrounded (multiple procs). + # Propagate a member start-failure to the script exit code — the reaper has + # no health port, so this is the only programmatic startup signal. + run_pg_queue_set "$LOG_LEVEL" "$CONCURRENCY" "$POOL_TYPE" || exit 1 else # Resolve worker directory name from either WORKERS or PLUGGABLE_WORKERS WORKER_DIR_NAME="${WORKERS[$WORKER_TYPE]}" diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py index b9fe9f5984..91173dd722 100644 --- a/workers/tests/test_pg_reaper.py +++ b/workers/tests/test_pg_reaper.py @@ -325,5 +325,21 @@ def test_tick_sweeps_via_real_conn(self, barrier_conn): assert _ids(barrier_conn) == [] +# --- Entry point (the `python -m pg_queue_reaper` launch path) --- + + +class TestEntryPoint: + def test_main_module_reexports_real_main(self): + # The single point where `run-worker.sh reaper` / `python -m + # pg_queue_reaper` either works or dies on ImportError. Pin that the + # launcher module re-exports the real reaper main. + import importlib + + from queue_backend.pg_queue.reaper import main as real_main + + module = importlib.import_module("pg_queue_reaper.__main__") + assert module.main is real_main + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From f31238828b05bfe4b4629b78521f4a12f4a42fa3 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:50:15 +0530 Subject: [PATCH 13/65] =?UTF-8?q?UN-3556=20[FEAT]=20PG=20Queue=209d=20?= =?UTF-8?q?=E2=80=94=20reaper=20liveness=20probe=20(heartbeat=20+=20is=5Fl?= =?UTF-8?q?eader)=20(#2061)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3556 [FEAT] PG Queue 9d — reaper liveness probe (heartbeat + is_leader) Closes the gap flagged in #2059's review: a reaper that crashes after startup was invisible (opt-in skip in --status + no health port). Mirrors the consumer's liveness (UN-3544). - PgReaper heartbeat: _last_tick_monotonic stamped at the START of every tick (a standby tick counts as progress — liveness tracks the loop, not leadership) + seconds_since_last_tick() / is_tick_stale(). - ReaperLivenessServer: lean HTTP probe (mirrors the consumer's LivenessServer) — /health (also /healthz, /livez) returns 200 while the tick loop is fresh, 503 when stale. Payload also surfaces is_leader (which pod holds the lease — useful for 9e debugging). The 200/503 verdict is PURELY the heartbeat, never leadership (a standby is healthy) or DB reachability (a blip must not crash-loop a fine process). - main() wires it from WORKER_PG_REAPER_HEALTH_PORT (unset → no server, no stray port); staleness window from WORKER_PG_REAPER_HEALTH_STALE_SECONDS (default 30s, comfortably above the 5s tick interval). Bind failure degrades gracefully (logs, runs probe-less). - run-worker.sh: reserve port 8086 for the reaper, export the health-port env in the reaper special-case, document the two new env vars in --help. - Tests (+13, 37 total): heartbeat fresh/stale + tick refresh; liveness server 200-fresh / 503-stale / is_leader reflected / 404 / double-start; health staleness env default+override+invalid; server-disabled-when-no-port. Dev-tested live: `run-worker.sh reaper -d` → GET :8086/health → {"status":"healthy","check":"pg_reaper_tick","is_leader":true,...}. Co-Authored-By: Claude Opus 4.8 * UN-3556 [REFACTOR] Extract shared LivenessServer (SonarCloud duplication) SonarCloud flagged the new ReaperLivenessServer as duplicating the consumer's LivenessServer (~19 lines of HTTP-probe boilerplate, over the 3% new-code gate). Extracted one generic LivenessServer into queue_backend/pg_queue/liveness.py — parameterised by a freshness callable + the payload's check/age labels + an optional extra-status callable (the reaper's is_leader). Both sides are now thin subclasses that preserve their exact constructor signatures and wire payloads: - consumer LivenessServer(consumer, port=, stale_after=) → check="pg_queue_poll", seconds_since_last_poll (unchanged on the wire; its tests pass untouched). - ReaperLivenessServer(reaper, port=, stale_after=) → check="pg_reaper_tick", seconds_since_last_tick, is_leader. The boilerplate now lives once → duplication cleared, consumer behaviour preserved. reaper 37 tests + consumer liveness/health tests green; ruff clean. Co-Authored-By: Claude Opus 4.8 * UN-3556 [FEAT] Address review: validate health port, type/guard liveness, tests Toolkit review (10 findings; several already resolved by the dedup refactor 0d2b3f721 — the threading-alias, the query-strip comment, and the extract-shared-server follow-up itself): - [Medium] Port parse: extracted _reaper_health_port_from_env() — names the var on a bad value (no more context-free int('abc') crash) and range-checks 0-65535 at parse time, so an out-of-range value can't escape the bind catch as OverflowError inside start(). main() uses it. - [Medium] liveness.py: typed _httpd/_thread as HTTPServer|None / Thread|None via TYPE_CHECKING (was Any in the shared server) — restores the lifecycle invariant + type-checking on .shutdown()/.join()/etc. - [Low] LivenessServer.__init__ re-validates stale_after > 0 (a direct caller could otherwise build an always-503 probe). - [Low] bound_port docstring: clarified the port=0 / not-started case. - [Low] _DEFAULT_HEALTH_STALE_SECONDS comment references the interval constant, not a hard-coded "5s". - [Medium/Low test gaps] +9 tests: port-env helper (unset/empty/valid/non-int named/out-of-range); main() wiring (parsed port reaches the wiring + health stopped in finally; port=None when unset); _maybe_start_health_server OSError graceful-degrade → None + logger.exception; stale_after<=0 constructor guard. reaper 46 + consumer liveness 10 green; ruff clean. The shared-server extraction (flagged as a follow-up) was already done in 0d2b3f721. Co-Authored-By: Claude Opus 4.8 * UN-3556 [FEAT] Address Greptile: protect core payload + per-process log label - extra_status_fn could silently clobber core payload fields (status / check / age_key / stale_after_seconds) that a monitor reads. Now the handler builds extra fields first and overlays the core fields, so core ALWAYS wins — a future caller's extra dict can't corrupt the status a monitor parses. Test: an extra_status_fn returning {"status":"HACKED",...} leaves status "healthy" and check intact, while a non-reserved extra key is preserved. - The dedup refactor moved the consumer's liveness warnings to the shared liveness logger with generic text, so log-based filtering keyed on the old "PG-queue consumer: ..." would miss them. Added a log_label param (default "pg-queue"); consumer passes "pg-queue consumer", reaper "pg-queue reaper", so the messages stay attributable to the source process. 57 tests green (reaper 47 + consumer liveness 10); ruff clean. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/queue_backend/pg_queue/__init__.py | 4 + workers/queue_backend/pg_queue/consumer.py | 132 ++---------- workers/queue_backend/pg_queue/liveness.py | 178 ++++++++++++++++ workers/queue_backend/pg_queue/reaper.py | 125 +++++++++++- workers/run-worker.sh | 15 +- workers/tests/test_pg_reaper.py | 227 +++++++++++++++++++++ 6 files changed, 560 insertions(+), 121 deletions(-) create mode 100644 workers/queue_backend/pg_queue/liveness.py diff --git a/workers/queue_backend/pg_queue/__init__.py b/workers/queue_backend/pg_queue/__init__.py index 88d163ac14..ec228f4617 100644 --- a/workers/queue_backend/pg_queue/__init__.py +++ b/workers/queue_backend/pg_queue/__init__.py @@ -32,9 +32,11 @@ from .client import PgQueueClient, QueueMessage from .connection import create_pg_connection from .leader_election import LeaderLease, default_worker_id, lease_seconds_from_env +from .liveness import LivenessServer from .reaper import ( LeaderLeaseLike, PgReaper, + ReaperLivenessServer, TickOutcome, reaper_interval_from_env, sweep_expired_barriers, @@ -44,9 +46,11 @@ __all__ = [ "LeaderLease", "LeaderLeaseLike", + "LivenessServer", "PgQueueClient", "PgReaper", "QueueMessage", + "ReaperLivenessServer", "TaskPayload", "TickOutcome", "create_pg_connection", diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index 856a038a26..62771921c4 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -31,11 +31,9 @@ from ..fairness import FAIRNESS_HEADER_NAME from .client import PgQueueClient +from .liveness import LivenessServer as _BaseLivenessServer if TYPE_CHECKING: - from http.server import HTTPServer - from threading import Thread - from celery import Celery from .client import QueueMessage @@ -97,8 +95,7 @@ def __init__( # Otherwise min(poll_interval*2, backoff_max) shrinks the backoff # below poll_interval — it would decrease instead of grow. raise ValueError( - f"backoff_max ({backoff_max}) must be >= poll_interval " - f"({poll_interval})" + f"backoff_max ({backoff_max}) must be >= poll_interval ({poll_interval})" ) self.queue_name = queue_name self._client = client if client is not None else PgQueueClient() @@ -371,124 +368,25 @@ def _maybe_start_health_server( return server -class LivenessServer: - """Tiny HTTP liveness probe: 200 while the poll loop is fresh, else 503. - - Deliberately lean. A *liveness* probe must answer one question — "is this - process still making progress?" — and nothing else. It must NOT depend on - broker/API reachability or resource pressure: a transient backend blip or a - busy moment would otherwise make the orchestrator crash-loop an - otherwise-healthy consumer. So this intentionally does *not* reuse the - shared ``HealthChecker`` (which bundles api-connectivity / system-resource - checks meant for richer health reporting, not liveness) — it reports solely - on the poll-loop heartbeat (:meth:`PgQueueConsumer.is_poll_stale`). - - Serves ``/health`` (also ``/healthz``, ``/livez``) on ``0.0.0.0`` (all - interfaces — a container/k8s probe reaches it from outside the process) in a - daemon thread. Bind ``port=0`` to let the OS pick a free port (read back via - :attr:`bound_port`) — used in tests. Start once; :meth:`stop` returns it to - the inert state. +class LivenessServer(_BaseLivenessServer): + """Consumer poll-loop liveness — a thin wrapper over the shared + :class:`queue_backend.pg_queue.liveness.LivenessServer`, bound to the + consumer's heartbeat (``seconds_since_last_poll``). Same wire shape as before + (``/health`` → 200 fresh / 503 stale, ``check="pg_queue_poll"``). """ - _PATHS = frozenset({"/health", "/healthz", "/livez"}) - def __init__( self, consumer: PgQueueConsumer, *, port: int, stale_after: float ) -> None: - self._consumer = consumer - self._port = port - self._stale_after = stale_after - self._httpd: HTTPServer | None = None - self._thread: Thread | None = None - - def start(self) -> None: - import json - import threading - from http.server import BaseHTTPRequestHandler, HTTPServer - from urllib.parse import urlsplit - - if self._httpd is not None: - raise RuntimeError("LivenessServer already started") - - consumer = self._consumer - stale_after = self._stale_after - paths = self._PATHS - - class _Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - # Strip any query string — self.path includes it, so a probe - # like /health?foo=bar must still match. - if urlsplit(self.path).path not in paths: - self.send_response(404) - self.end_headers() - return - # One clock read so age and the healthy/stale verdict are - # derived from the same instant. - age = consumer.seconds_since_last_poll() - stale = age > stale_after - body = json.dumps( - { - "status": "unhealthy" if stale else "healthy", - "check": "pg_queue_poll", - "seconds_since_last_poll": round(age, 3), - "stale_after_seconds": stale_after, - } - ).encode() - self.send_response(503 if stale else 200) - self.send_header("Content-Type", "application/json") - self.end_headers() - try: - self.wfile.write(body) - except (BrokenPipeError, ConnectionResetError): - pass # client (probe) hung up mid-response — not our problem - - def log_message(self, *_: object) -> None: - pass # silence per-request access logging - - def log_error(self, fmt: str, *args: object) -> None: - # BaseHTTPRequestHandler routes errors through log_message too; - # don't let the pass above swallow them — surface to our logger. - logger.warning("pg-queue liveness handler: " + fmt, *args) - - def _serve(httpd: HTTPServer) -> None: - try: - httpd.serve_forever() - except Exception: - # A daemon thread dying silently would make /health stop - # answering (connection refused) with no breadcrumb. - logger.exception("pg-queue liveness server thread crashed") - - httpd = HTTPServer(("0.0.0.0", self._port), _Handler) - self._httpd = httpd - self._thread = threading.Thread( - target=_serve, args=(httpd,), daemon=True, name="pg-consumer-liveness" + super().__init__( + freshness_fn=consumer.seconds_since_last_poll, + stale_after=stale_after, + port=port, + check_name="pg_queue_poll", + age_key="seconds_since_last_poll", + thread_name="pg-consumer-liveness", + log_label="pg-queue consumer", ) - self._thread.start() - - @property - def bound_port(self) -> int: - """Actual listening port (resolves ``port=0``); the requested port if not started.""" - if self._httpd is not None: - return self._httpd.server_address[1] - return self._port - - def stop(self) -> None: - """Shut the server down. Defensive: never raises (called from a finally).""" - try: - if self._httpd is not None: - self._httpd.shutdown() - self._httpd.server_close() - if self._thread is not None: - self._thread.join(timeout=5) - if self._thread.is_alive(): - logger.warning( - "PG-queue consumer: liveness thread did not stop within 5s" - ) - except Exception: - logger.exception("PG-queue consumer: error stopping liveness server") - finally: - self._httpd = None - self._thread = None if __name__ == "__main__": diff --git a/workers/queue_backend/pg_queue/liveness.py b/workers/queue_backend/pg_queue/liveness.py new file mode 100644 index 0000000000..7256a0e2a4 --- /dev/null +++ b/workers/queue_backend/pg_queue/liveness.py @@ -0,0 +1,178 @@ +"""Shared tiny HTTP liveness probe for PG-queue processes. + +Both the consumer (poll loop) and the reaper (tick loop) need the same probe: +"is this loop still making progress?". This is the single implementation, +parameterised by a freshness callable + the payload's ``check``/age labels (and +an optional extra-status callable — e.g. the reaper's ``is_leader``). Each side +wraps it in a thin subclass with its own constructor shape. + +Deliberately lean. A *liveness* probe must answer one question — progress — and +nothing else. It must NOT depend on broker/DB reachability or resource pressure: +a transient backend blip or a busy moment would otherwise make the orchestrator +crash-loop an otherwise-healthy process. So this does not reuse the shared +``HealthChecker`` (which bundles api-connectivity / system-resource checks meant +for richer health reporting, not liveness). + +Serves ``/health`` (also ``/healthz``, ``/livez``) on ``0.0.0.0`` (a +container/k8s probe reaches it from outside the process) in a daemon thread. +Bind ``port=0`` to let the OS pick a free port (read back via :attr:`bound_port`) +— used in tests. Start once; :meth:`stop` returns it to the inert state. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from http.server import HTTPServer + from threading import Thread + +logger = logging.getLogger(__name__) + + +class LivenessServer: + """Generic liveness probe: 200 while ``freshness_fn()`` is within + ``stale_after`` seconds, else 503. + + ``check_name`` / ``age_key`` label the JSON payload; ``extra_status_fn`` + (optional) merges extra fields in (informational — it never affects the + 200/503 verdict, which is purely the freshness heartbeat). + """ + + _PATHS = frozenset({"/health", "/healthz", "/livez"}) + + def __init__( + self, + *, + freshness_fn: Callable[[], float], + stale_after: float, + port: int, + check_name: str, + age_key: str, + extra_status_fn: Callable[[], dict[str, Any]] | None = None, + thread_name: str = "pg-queue-liveness", + log_label: str = "pg-queue", + ) -> None: + # Re-validate here (not only at the env boundary): a direct caller could + # otherwise build an always-503 probe that crash-loops the pod. Mirrors + # the codebase's load-bearing re-validation convention (PgReaper.__init__). + if stale_after <= 0: + raise ValueError(f"stale_after must be positive, got {stale_after!r}") + self._freshness_fn = freshness_fn + self._stale_after = stale_after + self._port = port + self._check_name = check_name + self._age_key = age_key + self._extra_status_fn = extra_status_fn + self._thread_name = thread_name + # Prefixes the (now-shared) log messages so they stay attributable to the + # source process after the consumer/reaper extraction (e.g. "pg-queue + # consumer" / "pg-queue reaper") — they all log via this module's logger. + self._log_label = log_label + self._httpd: HTTPServer | None = None + self._thread: Thread | None = None + + def start(self) -> None: + import json + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + from urllib.parse import urlsplit + + if self._httpd is not None: + raise RuntimeError("LivenessServer already started") + + freshness_fn = self._freshness_fn + stale_after = self._stale_after + paths = self._PATHS + check_name = self._check_name + age_key = self._age_key + extra_status_fn = self._extra_status_fn + log_label = self._log_label + + class _Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + # Strip any query string — a probe like /health?foo=bar must match. + if urlsplit(self.path).path not in paths: + self.send_response(404) + self.end_headers() + return + # One clock read so age and the healthy/stale verdict share an + # instant. The verdict is purely freshness — extra_status_fn + # fields are informational and never flip it. + age = freshness_fn() + stale = age > stale_after + # Extra fields first, then overlay the core fields — so a caller's + # extra_status_fn can NEVER clobber status/check/age_key/ + # stale_after_seconds (which a monitor reads): core always wins. + payload: dict[str, Any] = {} + if extra_status_fn is not None: + payload.update(extra_status_fn()) + payload.update( + { + "status": "unhealthy" if stale else "healthy", + "check": check_name, + age_key: round(age, 3), + "stale_after_seconds": stale_after, + } + ) + body = json.dumps(payload).encode() + self.send_response(503 if stale else 200) + self.send_header("Content-Type", "application/json") + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass # client (probe) hung up mid-response — not our problem + + def log_message(self, *_: object) -> None: + pass # silence per-request access logging + + def log_error(self, fmt: str, *args: object) -> None: + # BaseHTTPRequestHandler routes errors through log_message too; + # don't let the pass above swallow them — surface to our logger. + logger.warning(f"{log_label} liveness handler: " + fmt, *args) + + def _serve(httpd: HTTPServer) -> None: + try: + httpd.serve_forever() + except Exception: + # A daemon thread dying silently would make /health stop + # answering (connection refused) with no breadcrumb. + logger.exception("%s liveness server thread crashed", log_label) + + httpd = HTTPServer(("0.0.0.0", self._port), _Handler) + self._httpd = httpd + self._thread = threading.Thread( + target=_serve, args=(httpd,), daemon=True, name=self._thread_name + ) + self._thread.start() + + @property + def bound_port(self) -> int: + """Actual listening port once started (resolves ``port=0`` to the + OS-chosen port). Before :meth:`start` / after :meth:`stop`, returns the + configured port value (which is ``0`` when the OS is asked to choose). + """ + if self._httpd is not None: + return self._httpd.server_address[1] + return self._port + + def stop(self) -> None: + """Shut the server down. Defensive: never raises (called from a finally).""" + try: + if self._httpd is not None: + self._httpd.shutdown() + self._httpd.server_close() + if self._thread is not None: + self._thread.join(timeout=5) + if self._thread.is_alive(): + logger.warning( + "%s liveness thread did not stop within 5s", self._log_label + ) + except Exception: + logger.exception("%s: error stopping liveness server", self._log_label) + finally: + self._httpd = None + self._thread = None diff --git a/workers/queue_backend/pg_queue/reaper.py b/workers/queue_backend/pg_queue/reaper.py index 64ac120965..ae05d8812b 100644 --- a/workers/queue_backend/pg_queue/reaper.py +++ b/workers/queue_backend/pg_queue/reaper.py @@ -43,6 +43,7 @@ from .connection import create_pg_connection from .leader_election import LeaderLease, default_worker_id +from .liveness import LivenessServer as _BaseLivenessServer if TYPE_CHECKING: from psycopg2.extensions import connection as PgConnection @@ -178,12 +179,24 @@ def __init__( self._owns_sweep_conn = sweep_conn is None self._running = False self._is_leader = False + # Liveness heartbeat: monotonic timestamp of the last tick start. A + # standby tick counts as progress too (the loop is alive), so this tracks + # loop liveness, not leadership. + self._last_tick_monotonic = time.monotonic() @property def is_leader(self) -> bool: """Whether this process currently holds leadership (last tick's view).""" return self._is_leader + def seconds_since_last_tick(self) -> float: + """Seconds since the last tick started — the liveness heartbeat age.""" + return time.monotonic() - self._last_tick_monotonic + + def is_tick_stale(self, stale_after_seconds: float) -> bool: + """Whether the loop has gone quiet past ``stale_after_seconds``.""" + return self.seconds_since_last_tick() > stale_after_seconds + def _get_sweep_conn(self) -> PgConnection: # Recreate only an OWNED missing/closed connection; an injected one is the # caller's and is never swapped (mirrors LeaderLease / PgQueueClient). @@ -208,6 +221,9 @@ def _discard_owned_sweep_conn(self) -> None: def tick(self) -> TickOutcome: """One cycle: maintain leadership, then sweep iff leader.""" + # Heartbeat at the START of the cycle: a tick that begins but then errors + # still proves the loop is running (the error path is caught by run()). + self._last_tick_monotonic = time.monotonic() if self._is_leader: try: still_leader = self._lease.renew() @@ -297,10 +313,117 @@ def _install_signal_handlers(self) -> None: ) +# Default staleness window for the liveness probe. Comfortably above the default +# tick interval (_DEFAULT_REAPER_INTERVAL_SECONDS) so a single slow cycle (a long +# sweep / DB blip) doesn't flap the probe; the durable rationale is the headroom +# ratio — an operator tightening the interval should tighten this too. +_DEFAULT_HEALTH_STALE_SECONDS = 30.0 + + +class ReaperLivenessServer(_BaseLivenessServer): + """Reaper tick-loop liveness — a thin wrapper over the shared + :class:`queue_backend.pg_queue.liveness.LivenessServer`, bound to the reaper's + heartbeat (``seconds_since_last_tick``) and surfacing ``is_leader`` (which pod + holds the lease — informational; the 200/503 verdict is purely the heartbeat, + so a standby is healthy). + """ + + def __init__(self, reaper: PgReaper, *, port: int, stale_after: float) -> None: + super().__init__( + freshness_fn=reaper.seconds_since_last_tick, + stale_after=stale_after, + port=port, + check_name="pg_reaper_tick", + age_key="seconds_since_last_tick", + extra_status_fn=lambda: {"is_leader": reaper.is_leader}, + thread_name="pg-reaper-liveness", + log_label="pg-queue reaper", + ) + + +def _reaper_health_stale_from_env() -> float: + raw = os.getenv("WORKER_PG_REAPER_HEALTH_STALE_SECONDS") + if raw is None or raw == "": + return _DEFAULT_HEALTH_STALE_SECONDS + try: + value = float(raw) + except ValueError as exc: + raise ValueError( + f"WORKER_PG_REAPER_HEALTH_STALE_SECONDS={raw!r} is not a number." + ) from exc + if value <= 0: + raise ValueError( + f"WORKER_PG_REAPER_HEALTH_STALE_SECONDS={value} must be positive." + ) + return value + + +def _reaper_health_port_from_env() -> int | None: + """Liveness port from ``WORKER_PG_REAPER_HEALTH_PORT`` (unset/empty → None, + i.e. no server). Validates + names the var here so a garbled value (a common + run-worker.sh shell-fallback mistake) fails with a clear message rather than a + context-free ``int('abc')`` crash — and so an out-of-range value is rejected + at parse time rather than escaping the bind catch as ``OverflowError`` inside + ``start()``. + """ + raw = os.getenv("WORKER_PG_REAPER_HEALTH_PORT") + if raw is None or raw == "": + return None + try: + port = int(raw) + except ValueError as exc: + raise ValueError(f"Invalid WORKER_PG_REAPER_HEALTH_PORT={raw!r}: {exc}") from exc + if not (0 <= port <= 65535): + raise ValueError(f"WORKER_PG_REAPER_HEALTH_PORT={port} out of range 0-65535") + return port + + +def _maybe_start_health_server( + reaper: PgReaper, *, port: int | None, stale_after: float +) -> ReaperLivenessServer | None: + """Start the liveness server when ``port`` is not None; else ``None``. + + A bind failure degrades gracefully — the probe is auxiliary and must never + stop the reaper from running; we log and continue probe-less. + """ + if port is None: + logger.info( + "PG-queue reaper: WORKER_PG_REAPER_HEALTH_PORT unset — liveness " + "server disabled" + ) + return None + server = ReaperLivenessServer(reaper, port=port, stale_after=stale_after) + try: + server.start() + except OSError: + logger.exception( + "PG-queue reaper: liveness server could not bind :%s — continuing " + "WITHOUT a probe", + port, + ) + return None + logger.info( + "PG-queue reaper: liveness server on :%s/health (stale after %ss)", + server.bound_port, + stale_after, + ) + return server + + def main() -> None: logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) lease = LeaderLease(default_worker_id()) - PgReaper(lease).run() + reaper = PgReaper(lease) + health = _maybe_start_health_server( + reaper, + port=_reaper_health_port_from_env(), + stale_after=_reaper_health_stale_from_env(), + ) + try: + reaper.run() + finally: + if health is not None: + health.stop() if __name__ == "__main__": diff --git a/workers/run-worker.sh b/workers/run-worker.sh index 46c4b941fb..94ba46732f 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -101,6 +101,11 @@ declare -A WORKER_HEALTH_PORTS=( # only when WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT is exported (below); a bare # `python -m pg_queue_consumer` binds nothing. ["$PG_QUEUE_CONSUMER_TYPE"]="8090" + # pg_queue_reaper: 8086 — the one free slot in the 8080-8090 band (8086 sits + # between callback's 8085 and scheduler's 8087). Bound only when + # WORKER_PG_REAPER_HEALTH_PORT is exported (below); a bare + # `python -m pg_queue_reaper` binds nothing. + ["$PG_QUEUE_REAPER_TYPE"]="8086" ) # Opt-in workers: experimental and NOT part of the default "all" fleet, so @@ -141,7 +146,9 @@ Note: pg-queue-consumer overrides: WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE (source tasks to load, default notification), WORKER_PG_QUEUE_CONSUMER_QUEUE (queue to poll), and WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT (liveness server port, default 8090). Note: reaper overrides: WORKER_PG_ORCHESTRATOR_LEASE_SECONDS (lease window, default 10), - WORKER_PG_REAPER_INTERVAL_SECONDS (cycle interval, default 5). + WORKER_PG_REAPER_INTERVAL_SECONDS (cycle interval, default 5), + WORKER_PG_REAPER_HEALTH_PORT (liveness server port, default 8086), + WORKER_PG_REAPER_HEALTH_STALE_SECONDS (liveness staleness window, default 30). OPTIONS: -e, --env-file FILE Use specific environment file (default: .env) @@ -748,9 +755,11 @@ run_worker() { # PG queue reaper — a leader-elected SQL recovery loop (no Celery, no task # bootstrap). Override the celery command with the plain `python -m` entry. - # Tunables (lease window, cycle interval) come from env; no liveness port is - # wired yet (that's a follow-on slice), so it binds nothing. + # Tunables (lease window, cycle interval) come from env. The liveness server + # binds when WORKER_PG_REAPER_HEALTH_PORT is set (-p override wins, else the + # map default) — exported so the reaper's main() opts in. if [[ "$worker_type" == "$PG_QUEUE_REAPER_TYPE" ]]; then + export WORKER_PG_REAPER_HEALTH_PORT="${health_port:-${WORKER_HEALTH_PORTS[$worker_type]}}" cmd_args=("uv" "run" "python" "-m" "$PG_QUEUE_REAPER_TYPE") fi diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py index 91173dd722..a37db4494a 100644 --- a/workers/tests/test_pg_reaper.py +++ b/workers/tests/test_pg_reaper.py @@ -325,6 +325,233 @@ def test_tick_sweeps_via_real_conn(self, barrier_conn): assert _ids(barrier_conn) == [] +# --- Heartbeat + liveness probe --- + + +class TestHeartbeat: + def test_fresh_after_init(self): + reaper = PgReaper(_FakeLease(), interval_seconds=1, sweep_conn=object()) + assert reaper.seconds_since_last_tick() < 5 + assert reaper.is_tick_stale(1) is False + + def test_tick_refreshes_heartbeat(self): + # Even a standby tick (no sweep) counts as loop progress. + reaper = PgReaper( + _FakeLease(acquires=False), interval_seconds=0.01, sweep_conn=object() + ) + reaper._last_tick_monotonic = time.monotonic() - 100 # force stale + assert reaper.is_tick_stale(1) is True + reaper.tick() + assert reaper.is_tick_stale(1) is False + + +def _http_get(server, path="/health"): + import http.client + import json as _json + + conn = http.client.HTTPConnection("127.0.0.1", server.bound_port, timeout=3) + try: + conn.request("GET", path) + resp = conn.getresponse() + raw = resp.read() + return resp.status, (_json.loads(raw) if raw else None) + finally: + conn.close() + + +class TestLivenessServer: + def _server(self, reaper, *, stale_after=30.0): + server = reaper_mod.ReaperLivenessServer(reaper, port=0, stale_after=stale_after) + server.start() + return server + + def test_fresh_returns_200(self): + reaper = PgReaper( + _FakeLease(acquires=False), interval_seconds=0.01, sweep_conn=object() + ) + server = self._server(reaper) + try: + status, body = _http_get(server) + finally: + server.stop() + assert status == 200 + assert body["status"] == "healthy" + assert body["check"] == "pg_reaper_tick" + assert body["is_leader"] is False + + def test_stale_returns_503(self): + reaper = PgReaper(_FakeLease(), interval_seconds=0.01, sweep_conn=object()) + reaper._last_tick_monotonic = time.monotonic() - 100 + server = self._server(reaper, stale_after=1.0) + try: + status, body = _http_get(server) + finally: + server.stop() + assert status == 503 + assert body["status"] == "unhealthy" + + def test_is_leader_reflected(self): + reaper = PgReaper( + _FakeLease(acquires=True, renews=True), + interval_seconds=0.01, + sweep_conn=object(), + ) + with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + reaper.tick() # becomes leader + server = self._server(reaper) + try: + _, body = _http_get(server) + finally: + server.stop() + assert body["is_leader"] is True + + def test_extra_status_cannot_clobber_core_fields(self): + # A future extra_status_fn returning a reserved key must not corrupt the + # core payload a monitor reads — core fields always win. + from queue_backend.pg_queue.liveness import LivenessServer + + server = LivenessServer( + freshness_fn=lambda: 0.0, + stale_after=30, + port=0, + check_name="x", + age_key="age", + extra_status_fn=lambda: {"status": "HACKED", "check": "HACKED", "extra": 1}, + ) + server.start() + try: + status, body = _http_get(server) + finally: + server.stop() + assert status == 200 + assert body["status"] == "healthy" # core not clobbered + assert body["check"] == "x" # core not clobbered + assert body["extra"] == 1 # non-reserved extra preserved + + def test_unknown_path_404(self): + reaper = PgReaper(_FakeLease(), interval_seconds=1, sweep_conn=object()) + server = self._server(reaper) + try: + status, _ = _http_get(server, "/nope") + finally: + server.stop() + assert status == 404 + + def test_double_start_raises(self): + reaper = PgReaper(_FakeLease(), interval_seconds=1, sweep_conn=object()) + server = self._server(reaper) + try: + with pytest.raises(RuntimeError): + server.start() + finally: + server.stop() + + +class TestHealthEnv: + def test_stale_default_is_thirty(self, monkeypatch): + monkeypatch.delenv("WORKER_PG_REAPER_HEALTH_STALE_SECONDS", raising=False) + assert reaper_mod._reaper_health_stale_from_env() == pytest.approx(30.0) + + def test_stale_overridable(self, monkeypatch): + monkeypatch.setenv("WORKER_PG_REAPER_HEALTH_STALE_SECONDS", "10") + assert reaper_mod._reaper_health_stale_from_env() == pytest.approx(10.0) + + @pytest.mark.parametrize("bad", ["0", "-1", "x"]) + def test_stale_invalid_raises(self, monkeypatch, bad): + monkeypatch.setenv("WORKER_PG_REAPER_HEALTH_STALE_SECONDS", bad) + with pytest.raises(ValueError): + reaper_mod._reaper_health_stale_from_env() + + def test_health_server_disabled_when_no_port(self): + reaper = PgReaper(_FakeLease(), interval_seconds=1, sweep_conn=object()) + assert ( + reaper_mod._maybe_start_health_server(reaper, port=None, stale_after=30) + is None + ) + + def test_health_server_bind_failure_degrades_to_none(self, monkeypatch): + # The documented graceful-degrade path: a bind failure must NOT stop the + # reaper — log and run probe-less. + reaper = PgReaper(_FakeLease(), interval_seconds=1, sweep_conn=object()) + monkeypatch.setattr( + reaper_mod.ReaperLivenessServer, + "start", + MagicMock(side_effect=OSError("address already in use")), + ) + with patch.object(reaper_mod.logger, "exception") as logexc: + result = reaper_mod._maybe_start_health_server( + reaper, port=12345, stale_after=30 + ) + assert result is None + logexc.assert_called_once() + + def test_stale_after_non_positive_rejected(self): + # Constructor re-validates (not only the env reader) — an always-503 probe + # would crash-loop the pod. + reaper = PgReaper(_FakeLease(), interval_seconds=1, sweep_conn=object()) + with pytest.raises(ValueError, match="stale_after"): + reaper_mod.ReaperLivenessServer(reaper, port=0, stale_after=0) + + +class TestHealthPortEnv: + def test_unset_is_none(self, monkeypatch): + monkeypatch.delenv("WORKER_PG_REAPER_HEALTH_PORT", raising=False) + assert reaper_mod._reaper_health_port_from_env() is None + + def test_empty_is_none(self, monkeypatch): + monkeypatch.setenv("WORKER_PG_REAPER_HEALTH_PORT", "") + assert reaper_mod._reaper_health_port_from_env() is None + + def test_valid_port(self, monkeypatch): + monkeypatch.setenv("WORKER_PG_REAPER_HEALTH_PORT", "8086") + assert reaper_mod._reaper_health_port_from_env() == 8086 + + def test_non_int_raises_named(self, monkeypatch): + monkeypatch.setenv("WORKER_PG_REAPER_HEALTH_PORT", "abc") + with pytest.raises(ValueError, match="WORKER_PG_REAPER_HEALTH_PORT"): + reaper_mod._reaper_health_port_from_env() + + def test_out_of_range_raises(self, monkeypatch): + monkeypatch.setenv("WORKER_PG_REAPER_HEALTH_PORT", "99999") + with pytest.raises(ValueError, match="out of range"): + reaper_mod._reaper_health_port_from_env() + + +class TestMainWiring: + def _patch_main(self, monkeypatch): + # Don't open a DB connection or run the loop. + monkeypatch.setattr(reaper_mod, "LeaderLease", lambda _wid: _FakeLease()) + monkeypatch.setattr(reaper_mod.PgReaper, "run", lambda self, **_kw: None) + + def test_wires_health_port_and_stops_on_exit(self, monkeypatch): + self._patch_main(monkeypatch) + monkeypatch.setenv("WORKER_PG_REAPER_HEALTH_PORT", "0") + fake_health = MagicMock() + captured: dict = {} + + def fake_start(_reaper, *, port, stale_after): + captured["port"] = port + return fake_health + + monkeypatch.setattr(reaper_mod, "_maybe_start_health_server", fake_start) + reaper_mod.main() + assert captured["port"] == 0 # parsed int reached the wiring + fake_health.stop.assert_called_once() # stopped in the finally + + def test_no_health_when_port_unset(self, monkeypatch): + self._patch_main(monkeypatch) + monkeypatch.delenv("WORKER_PG_REAPER_HEALTH_PORT", raising=False) + captured: dict = {} + + def fake_start(_reaper, *, port, stale_after): + captured["port"] = port + return None + + monkeypatch.setattr(reaper_mod, "_maybe_start_health_server", fake_start) + reaper_mod.main() # must not raise even though health is None + assert captured["port"] is None + + # --- Entry point (the `python -m pg_queue_reaper` launch path) --- From 473603a97101fa291a93ce8dea6768a8f7a57994 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:12:53 +0530 Subject: [PATCH 14/65] =?UTF-8?q?UN-3560=20[FEAT]=20PG=20Queue=209=20?= =?UTF-8?q?=E2=80=94=20run-worker.sh=20`-L=20celery`=20log=20alias=20(#206?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3560 [FEAT] PG Queue 9 — run-worker.sh `-L celery` log alias `./run-worker.sh -L pg-queue` already tails the PG-queue set's logs, but there was no symmetric way to tail only the Celery set: `-L` (no arg) tails EVERYTHING (Celery + PG-queue consumer/reaper), since list_core_worker_dirs includes the PG worker dirs. Add a `-L celery` log alias (mirror of `-L pg-queue`): tails every worker log EXCEPT the PG-queue members (pg_queue_consumer, pg_queue_reaper). Logs-only — the Celery set is still run via 'all'. run-worker.sh only: CELERY_SET constant + a branch in tail_logs() + usage/examples. Dev-tested with stub PG logs: -L = 11 files, -L celery = 9 (PG excluded), -L pg-queue = 2 (PG only). bash -n clean. Co-Authored-By: Claude Opus 4.8 * UN-3560 [FIX] -L celery review — complement wording + single-source PG members + dedup Address PR #2066 toolkit review: - Comment accuracy (P1, x2): reword 'mirrors PG_QUEUE_SET' / 'mirror of the pg-queue alias' — the celery set is the COMPLEMENT (all minus the two PG members), in a different branch, not a mirror. Drop the directional 'below'. - Modeling (P2): add a single 'PG_QUEUE_MEMBERS' source of truth (readonly assoc array) so 'celery = all - pg-queue' stays correct by construction; the celery branch tests membership via it instead of hand-rolling consumer/reaper. - Simplification (P1): collapse the near-duplicate 'all' and 'celery' tail_logs branches into one loop with a membership-guarded skip. Deferred (P2, inherited): set-aware empty-result message for the shared zero-log guard — spans all three set aliases, best done as one follow-up. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/run-worker.sh | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/workers/run-worker.sh b/workers/run-worker.sh index 94ba46732f..68bb23aa11 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -31,6 +31,21 @@ readonly PG_QUEUE_CONSUMER_TYPE="pg_queue_consumer" readonly PG_QUEUE_REAPER_TYPE="pg_queue_reaper" # Set alias that launches the whole PG-queue group (consumer + reaper) together. readonly PG_QUEUE_SET="pg-queue" +# The PG-queue member worker dirs, as a set — the single source of truth for +# "which workers belong to the PG-queue transport". Keeps the `celery = all − +# pg-queue` complement correct by construction if a third member is ever added +# (callers test membership via ${PG_QUEUE_MEMBERS[$dir]:-} instead of +# hand-rolling the consumer/reaper pair). +declare -rA PG_QUEUE_MEMBERS=( + ["$PG_QUEUE_CONSUMER_TYPE"]=1 + ["$PG_QUEUE_REAPER_TYPE"]=1 +) +# Log-tail alias for the Celery transport: every worker EXCEPT the PG-queue +# members — the *complement* of the 'pg-queue' tail alias, so the two +# transports' logs can be tailed separately (-L celery vs -L pg-queue). +# Tail-only — there is no 'celery' run alias; this set is started via 'all', +# which by design omits the opt-in PG-queue workers. +readonly CELERY_SET="celery" # Available workers declare -A WORKERS=( @@ -163,7 +178,9 @@ OPTIONS: -r, --restart Kill matching worker(s) then relaunch (with WORKER_TYPE, restarts only that worker; without it, restarts all) -s, --status Show status of running workers - -L, --logs [WORKER] Live-tail worker log files (all if WORKER omitted) + -L, --logs [WORKER] Live-tail worker log files (all if WORKER omitted). + WORKER may also be a set: 'celery' (all but the + PG-queue members) or 'pg-queue' (consumer + reaper). -C, --clear-logs Delete worker .log files created by -d / 'all' runs -h, --help Show this help message @@ -200,6 +217,12 @@ EXAMPLES: # Live-tail all worker logs $0 -L + # Tail just the Celery set's logs (excludes the PG-queue workers) + $0 -L celery + + # Tail just the PG-queue set's logs (consumer + reaper) + $0 -L pg-queue + # Tail just one worker's log $0 -L general @@ -432,8 +455,15 @@ tail_logs() { local requested=$1 # may be empty → tail all local log_files=() - if [[ -z "$requested" || "$requested" == "all" ]]; then + if [[ -z "$requested" || "$requested" == "all" || "$requested" == "$CELERY_SET" ]]; then + # 'all' (and empty) tails every worker; 'celery' tails every worker + # EXCEPT the PG-queue members — the complement of the 'pg-queue' alias + # (handled in the else-branch) — so the two transports' logs can be + # tailed separately. Same resolve-and-append body for both, single-sourced. for d in $(list_core_worker_dirs) $(list_pluggable_worker_dirs); do + if [[ "$requested" == "$CELERY_SET" && -n "${PG_QUEUE_MEMBERS[$d]:-}" ]]; then + continue + fi local f f=$(resolve_log_file "$d") [[ -n "$f" ]] && log_files+=("$f") From d6cead2114012883918c4577283044f091417733 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:20:33 +0530 Subject: [PATCH 15/65] =?UTF-8?q?UN-3559=20[FEAT]=20PG=20Queue=209e=20PR?= =?UTF-8?q?=201=20=E2=80=94=20execution=20transport=20seam=20(inert)=20(#2?= =?UTF-8?q?062)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3559 [FEAT] PG Queue 9e PR 1 — execution transport seam (inert) Establish the per-execution transport seam for the 9e coupled-pipeline migration: the transport a workflow execution rides ("celery" | "pg_queue") is resolved once at the creation chokepoint and carried in the task payload. Inert — transport always resolves to "celery", so behaviour is unchanged. Design (chosen = payload-carry, not a WorkflowExecution column): workers/queue_backend/pg_queue/9e-design.md. - core: WorkflowTransport enum + DEFAULT_WORKFLOW_TRANSPORT (shared vocabulary). - backend: resolve_transport() hardwired to celery (signature shaped for PR 3's Flipt wiring); create-execution internal API returns "transport"; execute_workflow_async adds it to the async_execute_bin kwargs. - workers: scheduler threads transport from the create response into the dispatch kwargs; async_execute_bin_general / _execute_general_workflow carry it onto the live WorkflowContextData. - tests: backend resolver + enum (5), worker WorkflowContextData carry/default (2), dispatch characterisation updated (+ backend-resolved-transport thread). Out of scope: live PG routing + per-batch idempotency key (PR 2); Flipt canary wiring (PR 3); rollout (ops). Co-Authored-By: Claude Opus 4.8 * UN-3559 [FIX] 9e PR 1 review — fail-closed transport coercion + doc/test fixes Address PR #2062 review (PR Review Toolkit findings): - Validation/silent-failure: add normalize_transport() (core) — fail-closed coercion of any inbound transport to a known value (unknown/None -> celery + warn). Applied at the scheduler read boundary and in WorkflowContextData __post_init__, so a garbage payload value can't reach the PR 2 fan-out read. - Comment accuracy: WorkflowContextData.transport comment now present-tense (carried in PR 1; fan-out read lands in PR 2). - Dead-code hygiene: add 'transport' to EXECUTION_EXCLUDED_PARAMS so the legacy execute_bin -> create_workflow_execution path can't TypeError. - Two-resolution-sites: documented the deliberate two-site design + the PR 3 single-chokepoint requirement at execute_workflow_async. - transport.py: drop the unused logger; leave a PR-3 fail-closed marker. - Design doc: correct anchors (transport.py / internal_api_views / workflow_ helper, not execution.py:126), class name (WorkflowContextData not WorkflowExecutionContext), scope claims (stage-1 only in PR 1), and remove the hard-coded Flipt version/date/live-state. - Tests: normalize_transport (passthrough / invalid->celery / None / logging), WorkflowContextData invalid-transport coercion. Co-Authored-By: Claude Opus 4.8 * UN-3559 [FIX] 9e PR 1 — resolve transport after execution_id guard (greptile P1) Move the normalize_transport(...) extraction below the `if not execution_id` guard in _execute_scheduled_workflow. Previously it ran before the guard, so an error response with no execution_id logged a misleading `[exec:None]` context and discarded the computed transport. Now transport is only resolved once execution_id is known non-empty. Co-Authored-By: Claude Opus 4.8 * UN-3559 [FIX] 9e PR 1 — SonarCloud: drop unused resolve_transport params + dict literal Address SonarCloud code smells on PR #2062: - python:S1172 (x3): resolve_transport() no longer declares the unused workflow_id/pipeline_id/organization_id params — the PR-1 seam is inert and needs no inputs. PR 3 reintroduces them (keyed for Flipt) when it wires the evaluation; the two call sites (internal_api_views view, execute_workflow_async) now call resolve_transport() with no args. Tests updated. - Replace dict(...) constructor with a {...} literal in test_workflow_context_transport._make_context. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../workflow_manager/internal_api_views.py | 9 + .../workflow_v2/tests/__init__.py | 0 .../workflow_v2/tests/test_transport.py | 61 ++++ .../workflow_manager/workflow_v2/transport.py | 40 +++ .../workflow_v2/workflow_helper.py | 19 + .../core/src/unstract/core/data_models.py | 49 +++ workers/general/tasks.py | 5 + workers/queue_backend/pg_queue/9e-design.md | 329 ++++++++++++++++++ workers/scheduler/tasks.py | 19 +- workers/shared/models/execution_models.py | 20 ++ .../test_dispatch_sites_characterisation.py | 24 +- .../tests/test_workflow_context_transport.py | 51 +++ 12 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 backend/workflow_manager/workflow_v2/tests/__init__.py create mode 100644 backend/workflow_manager/workflow_v2/tests/test_transport.py create mode 100644 backend/workflow_manager/workflow_v2/transport.py create mode 100644 workers/queue_backend/pg_queue/9e-design.md create mode 100644 workers/tests/test_workflow_context_transport.py diff --git a/backend/workflow_manager/internal_api_views.py b/backend/workflow_manager/internal_api_views.py index 931ba94138..ac81e31aa0 100644 --- a/backend/workflow_manager/internal_api_views.py +++ b/backend/workflow_manager/internal_api_views.py @@ -20,6 +20,7 @@ from workflow_manager.workflow_v2.enums import ExecutionStatus from workflow_manager.workflow_v2.models import Workflow, WorkflowExecution +from workflow_manager.workflow_v2.transport import resolve_transport logger = logging.getLogger(__name__) @@ -320,11 +321,19 @@ def create_workflow_execution(request): # Handle tags logic if needed pass + # Resolve the transport this execution rides (9e). Decided once here, at + # the creation chokepoint, and returned so the caller carries it in the + # dispatched task's payload — not persisted on the row. PR 1 always + # resolves "celery"; PR 3 wires Flipt (keyed on workflow/pipeline/org) in + # resolve_transport(). + transport = resolve_transport() + return Response( { "execution_id": str(execution.id), "status": execution.status, "execution_log_id": execution.execution_log_id, # Return for workers to use + "transport": transport, } ) diff --git a/backend/workflow_manager/workflow_v2/tests/__init__.py b/backend/workflow_manager/workflow_v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/workflow_manager/workflow_v2/tests/test_transport.py b/backend/workflow_manager/workflow_v2/tests/test_transport.py new file mode 100644 index 0000000000..328850638e --- /dev/null +++ b/backend/workflow_manager/workflow_v2/tests/test_transport.py @@ -0,0 +1,61 @@ +"""Tests for the 9e transport-resolution seam. + +PR 1 is the *inert* seam: ``resolve_transport`` always returns Celery, so the +whole pipeline is byte-identical to today. These tests pin that contract so a +future change (PR 3, Flipt wiring) can't silently flip the default. +""" + +from unittest.mock import MagicMock + +from unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, + WorkflowTransport, + normalize_transport, +) +from workflow_manager.workflow_v2.transport import resolve_transport + + +class TestWorkflowTransportEnum: + def test_values(self): + assert WorkflowTransport.CELERY.value == "celery" + assert WorkflowTransport.PG_QUEUE.value == "pg_queue" + + def test_default_is_celery(self): + assert DEFAULT_WORKFLOW_TRANSPORT == WorkflowTransport.CELERY.value + + +class TestResolveTransport: + def test_resolves_celery_in_pr1(self): + """PR 1: always Celery (inert seam, no inputs). PR 3 adds the + workflow/pipeline/org params + Flipt evaluation.""" + assert resolve_transport() == WorkflowTransport.CELERY.value + + def test_result_is_a_valid_transport_value(self): + valid = {t.value for t in WorkflowTransport} + assert resolve_transport() in valid + + +class TestNormalizeTransport: + """The fail-closed coercion used at every untrusted read boundary.""" + + def test_recognized_values_pass_through(self): + assert normalize_transport("celery") == "celery" + assert normalize_transport("pg_queue") == "pg_queue" + + def test_unrecognized_value_falls_back_to_celery(self): + assert normalize_transport("celary") == DEFAULT_WORKFLOW_TRANSPORT + assert normalize_transport("pg-queue") == DEFAULT_WORKFLOW_TRANSPORT + + def test_none_and_empty_fall_back_to_celery(self): + assert normalize_transport(None) == DEFAULT_WORKFLOW_TRANSPORT + assert normalize_transport("") == DEFAULT_WORKFLOW_TRANSPORT + + def test_invalid_value_logs_a_warning_when_logger_given(self): + log = MagicMock() + assert normalize_transport("bogus", logger=log, context=" [exec:x]") == "celery" + log.warning.assert_called_once() + + def test_valid_value_does_not_warn(self): + log = MagicMock() + normalize_transport("pg_queue", logger=log) + log.warning.assert_not_called() diff --git a/backend/workflow_manager/workflow_v2/transport.py b/backend/workflow_manager/workflow_v2/transport.py new file mode 100644 index 0000000000..dfb22a181e --- /dev/null +++ b/backend/workflow_manager/workflow_v2/transport.py @@ -0,0 +1,40 @@ +"""Transport resolution for a workflow execution (9e). + +A workflow execution rides one transport end-to-end — legacy Celery/RabbitMQ +or the bespoke Postgres queue. The choice is resolved **once**, at the +execution-creation chokepoint, and returned to the caller so it can be carried +in the dispatched task's payload (see ``workers/queue_backend/pg_queue/ +9e-design.md``). It is deliberately **not** persisted on the ``WorkflowExecution`` +row: the payload is the single carrier, durable for PG via the queue row's +JSONB, and the giant shared table is never migrated for this work. + +PR 1 (this seam) hardwires the result to Celery, so behaviour is byte-identical +to today. PR 3 replaces the body with a Flipt evaluation (percentage rollout via +``entity_id`` hashing + per-org segment via ``context``), wrapped by an env +kill-switch and failing closed to Celery. +""" + +from __future__ import annotations + +from unstract.core.data_models import WorkflowTransport + + +def resolve_transport() -> str: + """Resolve the transport for a new workflow execution. + + Returns: + The transport value (a :class:`WorkflowTransport` value string). + + Note: + PR 1 always returns ``"celery"`` and takes no arguments (the inert seam + needs no inputs). PR 3 reintroduces ``workflow_id`` / ``pipeline_id`` / + ``organization_id`` parameters when it wires Flipt here — percentage + rollout via ``entity_id`` hashing + per-org segment via ``context`` — + and updates the two call sites (``internal_api_views`` view and + ``workflow_helper.execute_workflow_async``). PR 3 must wrap the Flipt + evaluation in ``try/except`` and fall back to + ``WorkflowTransport.CELERY.value`` (fail-closed), so a Flipt outage can + never break execution creation — mirroring ``normalize_transport`` on + the read side. + """ + return WorkflowTransport.CELERY.value diff --git a/backend/workflow_manager/workflow_v2/workflow_helper.py b/backend/workflow_manager/workflow_v2/workflow_helper.py index abe20703e1..29a067a306 100644 --- a/backend/workflow_manager/workflow_v2/workflow_helper.py +++ b/backend/workflow_manager/workflow_v2/workflow_helper.py @@ -57,15 +57,20 @@ from workflow_manager.workflow_v2.file_history_helper import FileHistoryHelper from workflow_manager.workflow_v2.models.execution import WorkflowExecution from workflow_manager.workflow_v2.models.workflow import Workflow +from workflow_manager.workflow_v2.transport import resolve_transport logger = logging.getLogger(__name__) # Parameters to exclude when calling create_workflow_execution +# (the classmethod takes no **kwargs, so any task kwarg not a real parameter — +# incl. the 9e ``transport`` carried in the async_execute_bin payload — must be +# filtered out here before it reaches the legacy ``execute_workflow`` path). EXECUTION_EXCLUDED_PARAMS = { "llm_profile_id", "hitl_queue_name", "hitl_packet_id", "custom_data", + "transport", } @@ -503,6 +508,19 @@ def execute_workflow_async( } org_schema = UserContext.get_organization_identifier() log_events_id = StateStore.get(Common.LOG_EVENTS_ID) + # Resolve the transport this execution rides (9e) and carry it in the + # task payload — the pipeline reads it to stay on one transport + # end-to-end. PR 1 always resolves "celery" (no behaviour change). + # + # NOTE (deliberate, two resolution sites): transport is resolved here + # for the API/manual/async paths and separately in + # internal_api_views.create_workflow_execution for the scheduler + # path. These are DISTINCT entry paths — never a double-resolution of + # the same execution — so today they cannot diverge. PR 3 makes each + # an independent Flipt evaluation; before then, both must be funnelled + # through a single per-execution chokepoint (so a percentage re-roll + # can't split one execution across transports). Tracked for PR 3. + transport = resolve_transport() async_execution: AsyncResult = celery_app.send_task( "async_execute_bin", args=[ @@ -521,6 +539,7 @@ def execute_workflow_async( "hitl_queue_name": hitl_queue_name, "hitl_packet_id": hitl_packet_id, "custom_data": custom_data, + "transport": transport, }, queue=queue, ) diff --git a/unstract/core/src/unstract/core/data_models.py b/unstract/core/src/unstract/core/data_models.py index 7e8e984a04..0336647570 100644 --- a/unstract/core/src/unstract/core/data_models.py +++ b/unstract/core/src/unstract/core/data_models.py @@ -200,6 +200,55 @@ class SourceConnectionType(str, Enum): API = "API" +class WorkflowTransport(str, Enum): + """Transport a single workflow execution rides end-to-end. + + The migration unit is the *execution*, not the task: every stage of a + coupled pipeline (async_execute → file-batch fan-out → callback) must run + on one transport, decided once at execution creation and carried in the + task payload (see ``9e-design.md``). ``CELERY`` is the legacy default; + ``PG_QUEUE`` is the bespoke Postgres queue. + """ + + CELERY = "celery" + PG_QUEUE = "pg_queue" + + +# Default transport when none is resolved/carried — keeps every pre-existing +# payload and caller on the legacy path until a transport is explicitly chosen. +DEFAULT_WORKFLOW_TRANSPORT = WorkflowTransport.CELERY.value + + +def normalize_transport(value: object, *, logger: Any = None, context: str = "") -> str: + """Coerce an inbound transport value to a known ``WorkflowTransport`` value. + + The transport crosses untrusted boundaries — a task payload, PG JSONB, an + older backend that omits the field — so a missing/garbage value must never + route an execution onto an unknown substrate. This **fails closed**: an + unrecognized value (``None``, ``"celary"``, ``""``) logs a warning (when a + ``logger`` is given) and falls back to :data:`DEFAULT_WORKFLOW_TRANSPORT` + (Celery). A recognized value passes through as its canonical string. + + Coerce-not-raise is deliberate, and differs from how + ``WorkflowContextData.workflow_type`` is validated: ``transport`` has an + explicit safe default by design (the whole point of the seam is reversible + fallback to Celery), so a bad value should degrade to Celery, not crash the + execution. ``context`` is an optional suffix for the log line (e.g. an + ``exec:`` tag) to make a version-skew warning traceable. + """ + try: + return WorkflowTransport(value).value + except ValueError: + if logger is not None: + logger.warning( + "Unrecognized workflow transport %r%s; falling back to %r", + value, + context, + DEFAULT_WORKFLOW_TRANSPORT, + ) + return DEFAULT_WORKFLOW_TRANSPORT + + class FileListingResult: """Result of listing files from a source.""" diff --git a/workers/general/tasks.py b/workers/general/tasks.py index edceacdb77..8d9e6e4bf4 100644 --- a/workers/general/tasks.py +++ b/workers/general/tasks.py @@ -51,6 +51,7 @@ # Import shared data models for type safety from unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, ExecutionStatus, FileBatchData, FileHashData, @@ -156,6 +157,7 @@ def async_execute_bin_general( pipeline_id: str | None = None, log_events_id: str | None = None, use_file_history: bool = False, + transport: str = DEFAULT_WORKFLOW_TRANSPORT, **kwargs: dict[str, Any], ) -> dict[str, Any]: """Lightweight general workflow execution task. @@ -267,6 +269,7 @@ def async_execute_bin_general( use_file_history, scheduled, schema_name, + transport=transport, **kwargs, ) @@ -462,6 +465,7 @@ def _execute_general_workflow( use_file_history: bool, scheduled: bool, schema_name: str, + transport: str = DEFAULT_WORKFLOW_TRANSPORT, **kwargs: dict[str, Any], ) -> dict[str, Any]: """Execute general workflow specific logic for ETL/TASK workflows. @@ -522,6 +526,7 @@ def _execute_general_workflow( "execution_mode": execution_mode, }, is_scheduled=scheduled, + transport=transport, ) logger.info( diff --git a/workers/queue_backend/pg_queue/9e-design.md b/workers/queue_backend/pg_queue/9e-design.md new file mode 100644 index 0000000000..78f4393d62 --- /dev/null +++ b/workers/queue_backend/pg_queue/9e-design.md @@ -0,0 +1,329 @@ +# 9e — Coupled-Pipeline Migration (Design) + +**Status:** design locked, pre-implementation +**Epic:** UN-3445 (PG Queue) · **Phase:** 9e +**Predecessors:** 9a–9c (dispatch seam + PG consumer), 9d (leader-election lease + reaper / barrier-orphan sweep) — all merged. + +This document defines how a **whole workflow execution** is migrated from the +legacy Celery/RabbitMQ transport to the bespoke Postgres queue, and why the +transport choice is **carried in the task payload** rather than persisted on a +shared model column. + +--- + +## 1. The problem 9e solves + +Earlier slices (9a–9c) added a `dispatch()` seam that routes a task by +**name** via `select_backend(task_name)` against the `WORKER_PG_QUEUE_ENABLED_TASKS` +allow-list. That is correct for **leaf / independent** tasks, but it cannot +migrate the execution pipeline, because the pipeline is a **coupled, multi-stage +fan-out/fan-in**: + +``` +async_execute_bin → fan-out: N × process_file_batch → fan-in: callback → destination + (stage 1) (stage 2, parallel) (stage 3) +``` + +Two facts make this hard: + +1. **The worker code is shared.** The same `process_file_batch` / + orchestration code runs whether a **Celery worker** (pulled from RabbitMQ) + or a **PG consumer** (polled from `pg_queue_message` via `SELECT … FOR UPDATE + SKIP LOCKED`) picked the task up. The running task does **not** inherently + know which transport it is on. + +2. **Stages 2 and 3 are enqueued from inside the workers**, not by the backend. + The worker running `async_execute_bin` is the one that must enqueue the file + batches *and* set up the barrier/callback — so **it** has to know whether to + use a Celery `chord` or a PG enqueue + `PgBarrier`. + +> **Migration coherence rule.** An execution must run **entirely** on one +> transport, end-to-end. The migration unit is the **execution**, not the task. +> A pipeline that starts on PG must fan-out, fan-in, and recover on PG; likewise +> for Celery. Splitting a single execution across transports (e.g. a callback +> looking for a Celery chord whose counter actually lives in `pg_barrier_state`) +> is the failure mode this phase is designed to prevent. + +So `select_backend(task_name)` (per-task, name-based) is **insufficient**: the +same task name must go to PG for one execution and Celery for another. We need a +**per-execution** transport decision that every stage can read. + +--- + +## 2. Decision split: Flipt **decides**, the payload **remembers** + +These are two distinct jobs and must not be conflated. + +### Flipt = make the decision (once, at creation) + +The rollout question — *"should this **new** execution ride PG queue?"* — is +answered **once**, at the single creation chokepoint, using the existing +`@unstract/flags` Flipt infrastructure +(`check_feature_flag_status(flag_key, entity_id, context)`): + +- `entity_id` → consistent **percentage-rollout** hashing. +- `context={"organization_id": ...}` → **per-org** segment targeting. + +This is the only place Flipt is consulted. + +#### Flipt flag contract (fixed) + +PR 3's `resolve_transport` reads this exact flag. Live rollout state (whether the +flag exists in a given env, current rollout %) is tracked in the PR / ticket, not +here — this table is just the durable contract PR 3 codes against. + +| Property | Value | +|----------|-------| +| Flipt version | the `flipt` image pinned in `docker/docker-compose-dev-essentials.yaml` (single source — don't duplicate the tag here) | +| Flag type | **Boolean** (binary decision — no variant/payload needed) | +| Key | `pg_queue_execution_enabled` | +| Default value | **`false`** → legacy Celery (fails closed; returned when no rollout matches) | +| Rollouts | percentage (0%→ramp) + optional per-org segment, added in PR 3 / rollout ops | +| Helper | `check_feature_flag_status(flag_key="pg_queue_execution_enabled", entity_id=…, context={"organization_id": org_id})` → `evaluate_boolean`; already fails closed to `False` on any error / when `FLIPT_SERVICE_AVAILABLE != "true"` | +| Mapping | `True → "pg_queue"`, `False → "celery"` | +| `entity_id` (stickiness) | **TBD in PR 3** — `execution_id` (per-execution bucketing) or `organization_id` (whole orgs move together). Must be stable so an in-flight execution never re-buckets. | + +Boolean (not Variant) because the choice is binary and Flipt Boolean flags +natively carry the two knobs we need: a **percentage rollout** (the canary, +sticky via `entity_id`) **plus segment rollouts** (per-org). Variant would +require Variants + Segments + Rules all configured or it returns `match=False`. + +### Why Flipt cannot also be the per-stage source of truth + +Three hard facts (verified against `unstract/flags/src/unstract/flags/`): + +1. **The answer changes; the execution doesn't.** A percentage rollback + (10% → 0%) or a segment edit flips Flipt's answer. But an execution already + in flight on PG **must finish on PG**. Re-querying Flipt per stage would + re-route a half-done execution → split-brain. +2. **Flipt fails closed to `False`.** `FliptClient.service_available` defaults + `false`, and every error path returns `False` (`except Exception: return + False`). A transient gRPC blip during the callback stage would silently say + "Celery" and orphan a live PG execution. Per-stage coherence can never depend + on a best-effort network call. +3. **The reaper has no Flipt context.** It recovers executions it did not + dispatch; Flipt can only say "what would a *new* execution be now," never + "what *was* this one dispatched as." + +**Therefore:** evaluate Flipt once at creation → write the answer into the +**first task's payload** → never consult Flipt again for that execution. + +--- + +## 3. Chosen design — transport carried in the task payload + +The transport string (`"celery"` | `"pg_queue"`) is decided at +`create_workflow_execution` and threaded through the pipeline **in the task +payload / `ExecutionContext`** — never read from a shared DB column. + +### Why payload-carry is the right home + +- **It is not extra.** The shared worker code already *needs* the transport to + pick the next-stage topology (chord vs PG+PgBarrier). Putting it in the + payload satisfies that need directly — no separate mechanism. +- **It is durable for PG without any new column.** A PG-routed dispatch + serialises `kwargs` into the `pg_queue_message.message` JSONB column + (`TaskPayload`, see `pg_queue/task_payload.py`). If a worker crashes and the + task is redelivered on visibility-timeout, the transport comes back **with the + redelivered payload**. Recovery is covered for free. +- **The reaper needs no flag to classify executions.** The reaper only ever acts + on **PG-specific tables** (`pg_barrier_state`, `pg_queue_message`) — presence + there *is* "this is a PG execution." Celery executions never appear in those + tables, so there is nothing to disambiguate. When the reaper needs the + transport (e.g. future pipeline re-enqueue), it reads it from the payload it + already finds in the PG row. +- **All PG-specific durable state stays in PG-specific tables.** Those tables are + `DROP`-ped wholesale when the feature is retired. The huge, shared + `WorkflowExecution` table is **never altered, never migrated** for this work. + +### Flow + +``` +backend: create_workflow_execution(...) + └─ resolve_transport(workflow, pipeline_id, org) # Flipt, once + → transport ∈ {"celery", "pg_queue"} + └─ dispatch("async_execute_bin", kwargs={..., "transport": transport}) + → routed to that transport's first hop + +worker: async_execute_bin (reads transport from its own kwargs / ExecutionContext) + └─ for each batch: dispatch("process_file_batch", kwargs={..., "transport": transport}) + └─ barrier/callback set up on the SAME transport (get_barrier honours it) + +worker: process_file_batch (transport rides in kwargs → re-stamped onward) +worker: callback (transport in kwargs → finalises on the same transport) +``` + +The live worker context `WorkflowContextData` +(`workers/shared/models/execution_models.py`) gains a +`transport: str = "celery"` field, populated from the inbound task kwargs. +Default `"celery"` keeps every pre-existing payload working unchanged. In PR 1 +the field is carried but **not yet re-emitted** to the stage-2/3 dispatches; the +downstream re-stamp + fan-out read land in PR 2. (Note: the unrelated +`WorkflowExecutionContext` dataclass in the same file is dead scaffolding and is +**not** the carrier.) + +--- + +## 4. Rejected alternative — a `transport` column on `WorkflowExecution` + +A persisted `WorkflowExecution.transport` column (single queryable source of +truth) was considered and **rejected** in favour of payload-carry. + +**What it would have bought:** a single `WHERE transport='pg_queue'` query for +dashboards and post-completion history. + +**Why rejected:** +- It touches the **shared, very large** `WorkflowExecution` table. Even though + the change is cheap (see cost note), all of PG-queue's other durable state + already lives in disposable PG-specific tables; adding one field to the shared + table is the *only* migration that would outlive the feature unless explicitly + dropped. +- The transport is **already required in the payload** for next-stage routing, + so a column would be redundant with information we must carry anyway. +- The observability gap is small: in-flight PG executions are queryable directly + via `pg_queue_message` / `pg_barrier_state`; historical classification comes + from logs/metrics we already emit. + +**Cost note (for the record).** The rejection is *not* on migration-cost +grounds — on Postgres 11+ both operations are cheap: +- **`ADD COLUMN`** with a nullable / constant (empty-string) default is a + **metadata-only** catalog change — no table rewrite, near-instant regardless + of row count (brief `ACCESS EXCLUSIVE` lock; run off-peak with a short + `lock_timeout`). +- **`DROP COLUMN`** is likewise metadata-only (marks the column dropped, no + rewrite). + +So the column would have been operationally safe; payload-carry wins on +**design cleanliness** (no shared-table coupling, transport not duplicated, all +PG state confined to droppable PG tables), not on migration cost. + +--- + +## 5. Deployment topology over the rollout + +The payload pin classifies each in-flight execution; it does **not** remove the +need for both consumers to be live during migration. Work is delivered on two +physically separate channels: + +- **Celery-pinned** executions → tasks land in **RabbitMQ** → only a **Celery + worker** can pick them up. +- **PG-pinned** executions → tasks land in **`pg_queue_message`** → only a **PG + consumer** (SKIP LOCKED poller) can pick them up. + +| Rollout stage | Consumers running | +|--------------------|----------------------------------------------------------------| +| Today / 0% | Celery workers only | +| Canary (e.g. 10%) | Celery workers **and** PG consumers + reaper, side by side | +| 100% / retire | PG consumers + reaper only; RabbitMQ + Celery workers torn down | + +Both consumer sets can be **co-located in one deployment** as parallel processes +— the `pg` set (PG consumers + leader-elected singleton reaper) plus the Celery +set — which is what `run-worker.sh` already supports (`pg` / `pg-queue` set vs +the Celery sets). "One fleet, two transports in parallel," not "one loop polling +the DB." + +--- + +## 6. Gating prerequisite — per-batch idempotency key + +PG queue is **at-least-once** (visibility-timeout redelivery); the pipeline +tasks are **non-idempotent** with `max_retries=0`. Before *any* real traffic +rides PG, `process_file_batch` must carry a **per-batch idempotency key** and +guard side effects with `INSERT … ON CONFLICT (execution_id, batch_index)` so a +redelivered batch cannot double-process. This is a **hard gate** before the +canary slice, not optional hardening. + +--- + +## 7. Slice breakdown — 3 PRs + +Three PRs (each one Jira sub-task), non-regressive, dev-tested against a running +stack before any PR is opened. Ordering is forced: **PR 1 → PR 2 → PR 3** (the +seam must exist before the PG path; Flipt is pointless before the PG path +works). All land on `feat/UN-3445-pg-queue-integration`, not `main`. + +> **Why 3 and not 6 or 1.** The earlier draft over-sliced. The scheduled/ETL +> path is *not* a separate PR — the chokepoint is universal (the scheduler mints +> via the same `create_workflow_execution`), so it rides PR 1 with at most one +> extra test. Rollout is *ops*, not a code PR. The idempotency key is the safety +> belt for the PG path and ships **with** it (PR 2), never separately ahead of +> it. We stop at 3 (not 1) to keep PR 1 **inert and isolated** — it merges with +> near-zero review risk and makes the branch carry `transport` everywhere, so +> PR 2 is reviewed purely as "the PG behaviour" (the highest-blast-radius code in +> the epic). Collapsing the inert seam into the risky rewire is the one merge to +> avoid. + +### PR 1 — transport seam (inert, no routing change) +- `resolve_transport(...)` helper (`backend/workflow_manager/workflow_v2/ + transport.py`), **hardwired to `"celery"`** (Flipt wiring is PR 3), called at + the two transport-emit sites: the scheduler-path HTTP view + (`internal_api_views.py:create_workflow_execution`, returns `transport`) and + the API/manual/async path (`workflow_helper.py:execute_workflow_async`, adds + it to the `async_execute_bin` kwargs). +- Add `transport: str = "celery"` to the live `WorkflowContextData` + (`workers/shared/models/execution_models.py`); populate from inbound kwargs + (NOT `WorkflowExecutionContext`, which is dead scaffolding). +- Thread `transport` into the **stage-1** dispatch kwargs only + (`workflow_helper.py` send + `scheduler/tasks.py` dispatch) across the entry + paths (API / async / scheduled-ETL/TASK / manual). Stage-2 fan-out + (`process_file_batch`) and stage-3 callback are **not** modified in PR 1 — + they carry no `transport` yet; the downstream re-stamp + the `pg_queue` branch + land in PR 2. +- Tests: resolver returns `"celery"`; `ExecutionContext` carries/defaults the + field; payload round-trips the field through `to_payload`/decode; scheduled + path carries it through. +- **Net behaviour change: none.** Pure plumbing behind a default. + +### PR 2 — live PG pipeline + idempotency gate (ship together) +- Implement the `pg_queue` branch at each dispatch site: enqueue next stage via + `dispatch()` onto PG; barrier via `get_barrier()` → `PgBarrier`. +- Fire-and-forget self-chaining (labs §5): each task enqueues the next and does + its own in-body barrier decrement; no chord/`.link` (the PG payload carries no + Celery `.link`). +- **Per-batch idempotency key in the same PR** — `process_file_batch` carries it; + side effects guarded by `INSERT … ON CONFLICT (execution_id, batch_index)` so a + vt-redelivered batch is a no-op. The PG path is never mergeable-and-enableable + without its guard. +- Still **defaults-off** (transport still resolves to `"celery"` from PR 1). +- Dev-test end-to-end on PG via a forced `transport="pg_queue"` test workflow; + characterisation test that a redelivered batch double-fires nothing. + +### PR 3 — Flipt canary wiring (turn the knob on) +- `resolve_transport` consults Flipt (`entity_id` = execution/org for %-hashing; + `context` = org segment), replacing the hardwired `"celery"`. Env kill-switch + wraps it for instant rollback; Flipt fails-closed to `"celery"`. +- Reads the **fixed flag contract** in §2 (key `pg_queue_execution_enabled`, + Boolean, default `false`). PR 3 adds the read + decides the `entity_id` + stickiness; provisioning the flag/rollouts per env is rollout ops. + +### Rollout — ops, not a PR +- Canary %, dashboards (off `pg_queue_message` / `pg_barrier_state`), runbook, + ramp + Celery teardown criteria. + +--- + +## 8. Key files (call-graph anchors) + +| Concern | Location | +|-----------------------------|----------| +| Transport resolve + emit | `workflow_v2/transport.py` (`resolve_transport`); emitted at `internal_api_views.py` (`create_workflow_execution` view → response, scheduler path) and `workflow_helper.py` (`execute_workflow_async` → `async_execute_bin` kwargs, API/manual/async path) | +| Row-builder (no transport) | `workflow_v2/execution.py` (`WorkflowExecutionServiceHelper.create_workflow_execution` — builds the row; does NOT resolve transport) | +| Entry paths | API deploy, async, scheduled-ETL/TASK, manual UI — all reach one of the two emit sites above | +| Pipeline dispatch sites | `workflow_helper.py` (stage-1 send, stage-2 batch signatures, stage-3 chord) — only stage-1 threads transport in PR 1 | +| Worker context (carrier) | `workers/shared/models/execution_models.py` (`WorkflowContextData` — NOT the dead `WorkflowExecutionContext`) | +| Dispatch seam | `workers/queue_backend/dispatch.py`, `routing.py` (`select_backend`) | +| PG task payload (durable) | `workers/queue_backend/pg_queue/task_payload.py` (`TaskPayload` → `pg_queue_message.message` JSONB) | +| Barrier factory | `workers/queue_backend/__init__.py` (`get_barrier`) | +| Barrier impls | `barrier.py` (chord), `redis_barrier.py`, `pg_barrier.py` | +| Barrier invocation | `workers/shared/workflow/execution/orchestration_utils.py` | +| Reaper / sweep | `workers/queue_backend/pg_queue/reaper.py` | + +--- + +## 9. References + +- Labs architecture: `labs/workflow-execution-architecture/architecture-explorer-v2.html` + and `labs/workflow-execution-architecture/docs/` — §5 fire-and-forget + self-chaining is the target model for slice 3. +- 9d (merged): leader-election lease (`leader_election.py`) + reaper / + barrier-orphan sweep (`reaper.py`). diff --git a/workers/scheduler/tasks.py b/workers/scheduler/tasks.py index a152b98aed..7518a4b930 100644 --- a/workers/scheduler/tasks.py +++ b/workers/scheduler/tasks.py @@ -23,7 +23,12 @@ from shared.patterns.notification.helper import trigger_notification from shared.utils.api_client_singleton import get_singleton_api_client -from unstract.core.data_models import NotificationPayload, NotificationSource +from unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, + NotificationPayload, + NotificationSource, + normalize_transport, +) # Import the exact backend logic to ensure consistency @@ -137,6 +142,17 @@ def _execute_scheduled_workflow( pipeline_id=context.pipeline_id, ) + # Transport this execution rides (9e), decided by the backend at creation + # and carried in the dispatched task's payload. Absent key (older backend) + # → celery default; a present-but-unrecognized value (version skew) is + # coerced to celery with a loud warning rather than dispatched onto an + # unknown substrate (fail-closed). + transport = normalize_transport( + workflow_execution.get("transport", DEFAULT_WORKFLOW_TRANSPORT), + logger=logger, + context=f" [exec:{execution_id}]", + ) + logger.info( f"[exec:{execution_id}] [pipeline:{context.pipeline_id}] Created workflow execution for scheduled pipeline {context.pipeline_name}" ) @@ -163,6 +179,7 @@ def _execute_scheduled_workflow( kwargs={ "use_file_history": context.use_file_history, "pipeline_id": context.pipeline_id, + "transport": transport, }, queue=QueueName.GENERAL, fairness=FairnessKey( diff --git a/workers/shared/models/execution_models.py b/workers/shared/models/execution_models.py index e9149b4e9a..ca5c332785 100644 --- a/workers/shared/models/execution_models.py +++ b/workers/shared/models/execution_models.py @@ -4,13 +4,16 @@ replacing fragile dictionary-based parameter passing with type-safe structures. """ +import logging from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any # Import shared domain models from core from unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, ExecutionStatus, PreCreatedFileData, + normalize_transport, serialize_dataclass_to_dict, ) @@ -20,6 +23,8 @@ from ..enums import PipelineType +logger = logging.getLogger(__name__) + @dataclass class WorkflowExecutionContext: @@ -385,6 +390,11 @@ class WorkflowContextData: settings: dict[str, Any] | None = None metadata: dict[str, Any] | None = None is_scheduled: bool = False + # Transport this execution rides end-to-end (9e). Populated from the inbound + # task payload; the fan-out read that keeps the pipeline on one transport + # lands in PR 2. Defaults to legacy Celery; coerced fail-closed in + # __post_init__ so a garbage payload value can't reach the PR 2 read site. + transport: str = DEFAULT_WORKFLOW_TRANSPORT pre_created_file_executions: dict[str, PreCreatedFileData] = field( default_factory=dict ) @@ -414,6 +424,16 @@ def __post_init__(self): f"Must be one of: {valid_types}" ) + # Coerce transport fail-closed: a garbage payload value (version skew, + # bad PG JSONB) degrades to Celery here rather than reaching the PR 2 + # fan-out read. Unlike workflow_type (no safe default → raise), transport + # is reversible by design, so it falls back instead of crashing. + self.transport = normalize_transport( + self.transport, + logger=logger, + context=f" (execution {self.execution_id})", + ) + @property def file_count(self) -> int: """Get the number of files in this workflow context.""" diff --git a/workers/tests/test_dispatch_sites_characterisation.py b/workers/tests/test_dispatch_sites_characterisation.py index d07721bdb2..48ffe33811 100644 --- a/workers/tests/test_dispatch_sites_characterisation.py +++ b/workers/tests/test_dispatch_sites_characterisation.py @@ -219,7 +219,12 @@ def test_dispatch_positional_args_layout(self): assert args[4] is True # scheduled flag (always True here) def test_dispatch_kwargs_layout(self): - """Kwargs MUST contain use_file_history and pipeline_id.""" + """Kwargs MUST contain use_file_history, pipeline_id, and transport. + + ``transport`` (9e) is carried in the task payload so the pipeline stays + on one transport end-to-end. It defaults to ``"celery"`` when the + create-execution response omits it (older backend / inert PR 1). + """ from scheduler.tasks import _execute_scheduled_workflow ctx = self._make_context() @@ -232,8 +237,25 @@ def test_dispatch_kwargs_layout(self): assert kwargs == { "use_file_history": True, "pipeline_id": "pipe-007", + "transport": "celery", + } + + def test_dispatch_carries_backend_resolved_transport(self): + """The transport the backend returns from create-execution is threaded + verbatim into the dispatched task's payload (payload-carry, 9e).""" + from scheduler.tasks import _execute_scheduled_workflow + + api = MagicMock() + api.create_workflow_execution.return_value = { + "execution_id": "exec-123", + "transport": "pg_queue", } + with patch("scheduler.tasks.dispatch") as mock_dispatch: + _execute_scheduled_workflow(api, self._make_context()) + + assert mock_dispatch.call_args.kwargs["kwargs"]["transport"] == "pg_queue" + def test_no_dispatch_when_execution_creation_fails(self): """If api_client.create_workflow_execution returns no execution_id, the function bails out and never calls send_task.""" diff --git a/workers/tests/test_workflow_context_transport.py b/workers/tests/test_workflow_context_transport.py new file mode 100644 index 0000000000..b8db8d6434 --- /dev/null +++ b/workers/tests/test_workflow_context_transport.py @@ -0,0 +1,51 @@ +"""Tests for the 9e transport field carried on ``WorkflowContextData``. + +The transport a workflow execution rides is decided once at creation and +carried in the task payload; on the worker side it lands on the live +``WorkflowContextData`` so the fan-out (PR 2) can read it. PR 1 only adds the +field with a Celery default — these tests pin that it defaults and round-trips. +""" + +from unittest.mock import MagicMock + +from shared.models.execution_models import ( + WorkerOrganizationContext, + WorkflowContextData, +) + +from unstract.core.data_models import DEFAULT_WORKFLOW_TRANSPORT, WorkflowTransport + + +def _make_context(**overrides): + org_context = WorkerOrganizationContext( + organization_id="org-1", + api_client=MagicMock(), + ) + kwargs = { + "workflow_id": "wf-1", + "workflow_name": "wf-name", + "workflow_type": "TASK", + "execution_id": "exec-1", + "organization_context": org_context, + "files": {}, + } + kwargs.update(overrides) + return WorkflowContextData(**kwargs) + + +class TestWorkflowContextTransport: + def test_defaults_to_celery(self): + """A context built without a transport rides legacy Celery.""" + assert _make_context().transport == DEFAULT_WORKFLOW_TRANSPORT + assert _make_context().transport == WorkflowTransport.CELERY.value + + def test_carries_pg_queue_when_set(self): + ctx = _make_context(transport=WorkflowTransport.PG_QUEUE.value) + assert ctx.transport == "pg_queue" + + def test_invalid_transport_coerced_to_celery(self): + """A garbage payload value (version skew / bad PG JSONB) must fail closed + at construction, not reach the PR 2 fan-out read.""" + assert _make_context(transport="pg-queue").transport == "celery" + assert _make_context(transport="bogus").transport == "celery" + assert _make_context(transport=None).transport == "celery" From 59716f56bf39f471ab5a72035d1b8142898fb8aa Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:30:38 +0530 Subject: [PATCH 16/65] =?UTF-8?q?UN-3561=20[FEAT]=20PG=20Queue=209e=20PR?= =?UTF-8?q?=202a=20=E2=80=94=20live-PG-pipeline=20inert=20foundation=20(di?= =?UTF-8?q?spatch=20backend=20override=20+=20barrier=20decrement=20core)?= =?UTF-8?q?=20(#2067)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3561 [FEAT] PG Queue 9e PR 2a — live-PG-pipeline inert foundation (dispatch backend override + barrier decrement core) First slice of 9e PR 2 (the live PG execution pipeline), split 2a/2b/2c. 2a is the inert foundation: the two seams the live switch (2c) consumes, each with zero behaviour change on the default path. - dispatch(backend=...): per-call transport override. None (default, every call site today) keeps the env allow-list decision via select_backend — byte-identical. When set it wins over the allow-list, so 2c can route a whole execution's header/callback onto PG without opting their task names into WORKER_PG_QUEUE_ENABLED_TASKS (allow-list is for leaf tasks; the pipeline's migration unit is the execution). - Extract _barrier_pg_decrement(...) plain core out of the @worker_task barrier_pg_decr_and_check (now a thin delegator). 2c calls the core in-body on the PG-consumed path (a PG-consumed task fires no Celery .link, so the decrement runs in-body — fire-and-forget self-chaining). Inert by construction: default barrier backend is chord (Celery executions never import the PgBarrier module), and no call site passes backend=. Net behaviour change: NONE. Transport threading + live switch land in 2c; per-batch idempotency in 2b. Tests: dispatch backend-override (3) + decrement-core extraction (3, incl. real-PG in-body decrement + verbatim delegation). pg_barrier/dispatch/ barrier/routing suites green; ruff clean; worker-app bootstrap clean under WORKER_BARRIER_BACKEND=pg (both barrier tasks registered, get_barrier→PgBarrier). Co-Authored-By: Claude Opus 4.8 * UN-3561 address review (muhammad-ali-e): loud in-transaction guard, resolve_backend helper, comment/test fixes - pg_barrier: enforce the "own committed transaction" decrement contract loudly — _barrier_pg_decrement raises at entry if the shared connection is mid-transaction (was prose-only; the 2c in-body caller is the real risk). Safe for existing paths: Celery .link enters idle, tests use autocommit conns → always idle. Fix the inaccurate "one call = one _cursor() txn" parenthetical (the delete paths open a 2nd txn) and drop the rot-prone "(fire-and-forget self-chaining)" jargon. - routing: extract resolve_backend(task_name, override) — the override-wins- else-allow-list precedence now lives in one self-documenting place (2c reuses it); dispatch() calls it instead of inlining the None-means-auto rule. - dispatch: reword the backend= docstring — avoid the "payload" term collision (local to_payload var) and the stale routing.py cross-ref; point at the live carrier WorkflowContextData.transport + 9e-design.md. - tests: +fairness-reaches-row on the backend= override path; +real-row wrapper test that catches core-param drift (the mocked delegation test couldn't); +open-transaction guard test. Kept the mocked test as the keyword-forwarding pin. Deferred (LOW, reviewer-aligned): BarrierDecrementResult TypedDict union — lands in 2c when the in-body caller actually branches on the status. Co-Authored-By: Claude Opus 4.8 * UN-3561 address greptile (3× P2): docstring refs + routing-log suppression note All doc/comment-only, no logic change. greptile gave 4/5 "safe to merge"; these are the staleness its findings flagged, introduced by the resolve_backend extraction in the prior review round: - dispatch module docstring + routing "Scaffold posture" now name resolve_backend (wrapping select_backend) as the routing seam, not select_backend alone. - Note at the _pg_routing_logged log-once site that it's keyed on task name only, so an override-then-allow-list cutover won't re-announce (benign: override = pipeline headers vs allow-list = leaf tasks, no overlap expected; the allow-list config is still announced by _log_allow_list_once). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/queue_backend/dispatch.py | 34 +++++++++--- workers/queue_backend/pg_barrier.py | 68 +++++++++++++++++++---- workers/queue_backend/routing.py | 25 ++++++++- workers/tests/test_dispatch_pg.py | 83 +++++++++++++++++++++++++++++ workers/tests/test_pg_barrier.py | 75 ++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 17 deletions(-) diff --git a/workers/queue_backend/dispatch.py b/workers/queue_backend/dispatch.py index 7012b0a7e4..02b0b9636b 100644 --- a/workers/queue_backend/dispatch.py +++ b/workers/queue_backend/dispatch.py @@ -1,11 +1,12 @@ """Transport-agnostic task dispatch. -Routes each task to its transport via :func:`select_backend`: +Routes each task to its transport via :func:`resolve_backend` (which applies a +per-call ``backend`` override, else defers to :func:`select_backend`): - **Celery** (default) — a thin pass-through to ``current_app.send_task``. -- **PG Queue** — when a task is opted into ``WORKER_PG_QUEUE_ENABLED_TASKS``, - the task is serialised and enqueued to ``pg_queue_message`` (9b); the PG - consumer (9c) drains and runs it. +- **PG Queue** — when a task is opted into ``WORKER_PG_QUEUE_ENABLED_TASKS`` + (or pinned via a ``backend=`` override), the task is serialised and enqueued + to ``pg_queue_message`` (9b); the PG consumer (9c) drains and runs it. The default (empty allow-list) routes everything to Celery, so dispatch is unchanged unless an operator explicitly opts a task in. @@ -30,7 +31,7 @@ from .fairness import DEFAULT_PRIORITY, FairnessKey from .handle import DispatchHandle from .pg_queue import PgQueueClient, to_payload -from .routing import QueueBackend, select_backend +from .routing import QueueBackend, resolve_backend logger = logging.getLogger(__name__) @@ -77,14 +78,29 @@ def dispatch( kwargs: Mapping[str, Any] | None = None, queue: str | None = None, fairness: FairnessKey | None = None, + backend: QueueBackend | None = None, ) -> DispatchHandle: """Enqueue a task by name onto its selected transport. ``fairness`` is attached as the ``x-fairness-key`` header on the Celery path / serialised into the message on the PG path. Pass ``None`` for non-workflow worker tasks. + + ``backend`` is a per-call transport override (see + :func:`~queue_backend.routing.resolve_backend` for the precedence). When + ``None`` (the default, and every call site today) the transport is the env + allow-list decision via ``select_backend`` — behaviour is unchanged. When + set, it wins over the allow-list: this is the seam the execution-level PG + pipeline (9e PR 2c) uses to route a whole execution's header/callback + dispatches onto PG without opting their task *names* into + ``WORKER_PG_QUEUE_ENABLED_TASKS``. (The allow-list is for *leaf* tasks; the + coupled pipeline's migration unit is the whole execution — its transport is + resolved once at creation and travels on the execution's task kwargs onto + ``WorkflowContextData.transport``, see ``queue_backend/pg_queue/ + 9e-design.md``.) The override only forces the *transport*; it does not + bypass ``_enqueue_pg``'s no-silent-fallback contract. """ - if select_backend(task_name) is QueueBackend.PG: + if resolve_backend(task_name, backend) is QueueBackend.PG: return _enqueue_pg(task_name, args, kwargs, queue, fairness) headers = fairness.as_header() if fairness is not None else None @@ -115,6 +131,12 @@ def _enqueue_pg( # true regardless of send outcome, so a first-dispatch failure # (DB down / unmigrated) must not suppress the one announcement. # INFO so it survives a default log config; once-per-task bounds it. + # NOTE: keyed on task name only, not on *why* it routed to PG. If a name + # is first PG-routed via a ``backend=`` override and the operator later + # adds it to the allow-list, the allow-list cutover won't re-announce + # (already in the set). Benign given the usage split (override = pipeline + # headers, allow-list = leaf tasks, so no overlap expected); the + # allow-list config itself is still announced by _log_allow_list_once. _pg_routing_logged.add(task_name) logger.info( "PG-queue: routing task=%r to Postgres (queue=%r). Requires the " diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 4ebc46be87..5ed2e7d021 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -65,6 +65,7 @@ from typing import TYPE_CHECKING, Any import psycopg2 +import psycopg2.extensions from .barrier import CallbackDescriptor, barrier_ttl_seconds from .decorator import worker_task @@ -246,23 +247,53 @@ def enqueue( raise -@worker_task(name="barrier_pg_decr_and_check", max_retries=0) -def barrier_pg_decr_and_check( +def _barrier_pg_decrement( result: Any, *, execution_id: str, callback_descriptor: CallbackDescriptor, ) -> dict[str, Any]: - """Per-task ``link`` callback for :class:`PgBarrier`. - - Atomically appends this task's result and decrements ``remaining``. The - single task that drives ``remaining`` to 0 dispatches the aggregating - callback (then deletes the row). ``max_retries=0`` — a Celery retry would - replay the decrement and corrupt the count; an orphaned barrier is bounded - by ``expires_at`` instead. + """Atomic barrier decrement + last-task callback fire (substrate core). + + Appends this task's result and decrements ``remaining`` in one atomic + statement; the single caller that drives ``remaining`` to 0 dispatches the + aggregating callback (then deletes the row). This is the plain, in-body- + callable core shared by two entry points: + + - the Celery ``link`` callback :func:`barrier_pg_decr_and_check` (today's + only caller, behaviour unchanged); and + - the 9e PR 2c PG-consumed pipeline path, which will call this directly in + the header task's body — a PG-consumed task fires no ``.link``, so the + decrement must run in-body. + + Callers MUST run each decrement in its own committed transaction (the + decrement ``UPDATE`` runs in its own ``_cursor()`` txn) and MUST NOT retry + it: a replay re-runs the decrement and corrupts the count. This is enforced + loudly, not just in prose — entry raises if the shared connection is already + mid-transaction (see the guard below). The Celery wrapper additionally pins + ``max_retries=0``; an orphaned barrier is bounded by ``expires_at`` instead. """ from celery import current_app + # Enforce the "own committed transaction" contract loudly. A caller that + # invokes this inside an already-open transaction on the shared thread-local + # connection would hold the row lock across the call and let the outer txn's + # commit boundary replay the decrement — corrupting ``remaining`` and + # breaking the exactly-one-sees-zero serialisation, surfacing only as a + # barrier hung until ``expires_at`` (~6h). The Celery ``.link`` path always + # enters idle (``_cursor`` commits after every use); the 2c in-body caller + # must too. (Tests inject an autocommit connection → always idle → no trip.) + if ( + _get_conn().get_transaction_status() + != psycopg2.extensions.TRANSACTION_STATUS_IDLE + ): + raise RuntimeError( + f"[exec:{execution_id}] _barrier_pg_decrement entered with an open " + f"transaction on the shared connection — each decrement MUST run in " + f"its own committed transaction (see this function's docstring). " + f"Refusing to proceed to avoid corrupting the barrier counter." + ) + try: # No default=str — a non-JSON-safe leaf must fail loudly here (it would # signal a BatchExecutionResult.to_dict() typed-boundary regression). @@ -372,6 +403,25 @@ def barrier_pg_decr_and_check( } +@worker_task(name="barrier_pg_decr_and_check", max_retries=0) +def barrier_pg_decr_and_check( + result: Any, + *, + execution_id: str, + callback_descriptor: CallbackDescriptor, +) -> dict[str, Any]: + """Per-task ``link`` callback for :class:`PgBarrier` (Celery entry point). + + Thin ``@worker_task`` wrapper around :func:`_barrier_pg_decrement` so the + decrement logic stays callable in-body (no ``.link``) on the PG-consumed + pipeline path (9e PR 2c). ``max_retries=0`` — a Celery retry would replay + the decrement and corrupt the count (see the core's contract). + """ + return _barrier_pg_decrement( + result, execution_id=execution_id, callback_descriptor=callback_descriptor + ) + + @worker_task(name="barrier_pg_abort", max_retries=0) def barrier_pg_abort( request: Any = None, diff --git a/workers/queue_backend/routing.py b/workers/queue_backend/routing.py index 82cce0d67e..228a296dfe 100644 --- a/workers/queue_backend/routing.py +++ b/workers/queue_backend/routing.py @@ -36,8 +36,9 @@ **Scaffold posture.** This module only makes the routing *decision*. In the current phase there is no PG consumer, so ``dispatch()`` still sends PG-selected tasks via Celery (the decision is observable in logs -but inert). ``select_backend()`` is the seam where the real PG dispatch -lands in a later phase. +but inert). ``resolve_backend()`` (which wraps the ``select_backend()`` +allow-list with the per-call override) is the seam where the real PG +dispatch lands in a later phase. **Observability.** Because the gate is silent-by-construction (a misrouted task still runs on Celery), the only signals are logs, emitted @@ -132,3 +133,23 @@ def select_backend(task_name: str) -> QueueBackend: if task_name in allow_list: return QueueBackend.PG return QueueBackend.CELERY + + +def resolve_backend(task_name: str, override: QueueBackend | None) -> QueueBackend: + """Resolve the transport for a dispatch, applying the per-call override. + + The single home for the override-wins-else-allow-list precedence so the + rule reads in one place (and ``dispatch()`` plus the 9e PR 2c call sites + share it): + + - ``override`` is ``None`` → defer to :func:`select_backend` (the env + allow-list) — the behaviour of every call site today. + - ``override`` is a :class:`QueueBackend` → it wins. This is how the + execution-level PG pipeline pins a whole execution's header/callback + dispatches to one transport regardless of the per-task allow-list (the + allow-list is for *leaf* tasks; the coupled pipeline's migration unit is + the execution). + + Never raises — both branches resolve to a valid :class:`QueueBackend`. + """ + return override if override is not None else select_backend(task_name) diff --git a/workers/tests/test_dispatch_pg.py b/workers/tests/test_dispatch_pg.py index e86d99193a..e9cde91824 100644 --- a/workers/tests/test_dispatch_pg.py +++ b/workers/tests/test_dispatch_pg.py @@ -19,6 +19,7 @@ from queue_backend.fairness import FairnessKey, WorkloadType from queue_backend.pg_queue import to_payload from queue_backend.routing import _ENABLED_TASKS_ENV_VAR as ENABLED_TASKS_ENV +from queue_backend.routing import QueueBackend # ``queue_backend.dispatch`` the attribute is the function (shadows the # submodule) — import the module explicitly to reach its globals. @@ -111,6 +112,88 @@ def test_celery_dispatch_unaffected(self): mock_get.assert_not_called() +class TestDispatchBackendOverride: + """The per-call ``backend=`` override (9e PR 2a inert foundation). + + When set it wins over the ``WORKER_PG_QUEUE_ENABLED_TASKS`` allow-list, so + the execution-level PG pipeline can route a whole execution's headers / + callback onto PG without opting their task *names* in. ``None`` (default) + preserves the allow-list decision exactly — every call site today. + """ + + def test_override_pg_forces_pg_without_allow_list(self, monkeypatch): + # Task name NOT in the (empty) allow-list → select_backend says Celery; + # the explicit override must still route it to PG. + captured: dict = {} + + class _Client: + def send(self, queue_name, payload, **kwargs): + captured.update(queue=queue_name, payload=payload, **kwargs) + return 11 + + monkeypatch.setattr(dispatch_mod, "_get_pg_client", lambda: _Client()) + with patch("queue_backend.dispatch.current_app") as mock_app: + handle = dispatch( + "pipeline_header", queue="general", backend=QueueBackend.PG + ) + mock_app.send_task.assert_not_called() + assert handle.id == "11" + assert captured["payload"]["task_name"] == "pipeline_header" + + def test_override_celery_forces_celery_despite_allow_list(self, monkeypatch): + # Task name IS opted into PG, but the override pins it back to Celery. + monkeypatch.setenv(ENABLED_TASKS_ENV, "pipeline_header") + with ( + patch("queue_backend.dispatch.current_app") as mock_app, + patch("queue_backend.dispatch._get_pg_client") as mock_get, + ): + dispatch("pipeline_header", queue="general", backend=QueueBackend.CELERY) + mock_app.send_task.assert_called_once() + mock_get.assert_not_called() + + def test_default_none_preserves_allow_list_decision(self, monkeypatch): + # No override → allow-list decides. Opted-in name → PG. + captured: dict = {} + + class _Client: + def send(self, queue_name, payload, **kwargs): + captured.update(queue=queue_name) + return 5 + + monkeypatch.setenv(ENABLED_TASKS_ENV, "leaf_task") + monkeypatch.setattr(dispatch_mod, "_get_pg_client", lambda: _Client()) + with patch("queue_backend.dispatch.current_app") as mock_app: + handle = dispatch("leaf_task", queue="general") + mock_app.send_task.assert_not_called() + assert handle.id == "5" + + def test_override_path_carries_fairness_to_row(self, monkeypatch): + # The override's whole purpose is to route an *execution's* dispatches — + # exactly the ones that carry a FairnessKey. The org/priority plumbing + # must work identically on the override path (not just the allow-list + # path), so a regression dropping fairness here would otherwise pass. + captured: dict = {} + + class _Client: + def send(self, queue_name, payload, **kwargs): + captured.update(queue=queue_name, **kwargs) + return 13 + + monkeypatch.setattr(dispatch_mod, "_get_pg_client", lambda: _Client()) + fairness = FairnessKey( + org_id="o", workload_type=WorkloadType.API, pipeline_priority=8 + ) + with patch("queue_backend.dispatch.current_app"): + dispatch( + "pipeline_header", + queue="general", + fairness=fairness, + backend=QueueBackend.PG, + ) + assert captured["priority"] == 8 + assert captured["org_id"] == "o" + + class TestDispatchPriorityWiring: """dispatch() carries fairness.pipeline_priority onto the PG row (mocked).""" diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index 0c1a635c1d..c2a9488560 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -23,6 +23,7 @@ from queue_backend.handle import BarrierHandle from queue_backend.pg_barrier import ( PgBarrier, + _barrier_pg_decrement, barrier_pg_abort, barrier_pg_decr_and_check, ) @@ -325,6 +326,80 @@ def test_registered_under_canonical_name(self): assert barrier_pg_decr_and_check.name == "barrier_pg_decr_and_check" +class TestDecrementCoreExtraction: + """The decrement logic lives in a plain ``_barrier_pg_decrement`` core so the + 9e PR 2c PG-consumed path can call it in-body (a PG-consumed task fires no + ``.link``). The Celery ``@worker_task`` is a thin delegator; both paths must + share one implementation (no drift). (Inert in 2a — no PG caller yet.) + """ + + def test_core_is_a_plain_callable_not_a_celery_task(self): + # A ``@worker_task`` exposes ``.name`` / ``.delay``; the in-body core + # must not, or callers could accidentally re-dispatch instead of running. + assert not hasattr(_barrier_pg_decrement, "name") + assert not hasattr(_barrier_pg_decrement, "delay") + + def test_core_runs_the_decrement_in_body(self, barrier_db): + # Called directly (no Celery), the core performs the atomic decrement. + _seed(barrier_db, "exec-core", 3) + out = _barrier_pg_decrement( + {"f": 1}, execution_id="exec-core", callback_descriptor=_CALLBACK + ) + assert out["status"] == "pending" + remaining, results = _row(barrier_db, "exec-core") + assert remaining == 2 + assert results == [{"f": 1}] + + def test_worker_task_produces_same_decrement_as_core(self, barrier_db): + # The real no-drift guard: the wrapper, run against a real seeded row, + # must produce the SAME observable decrement as a direct core call + # (mirror of test_core_runs_the_decrement_in_body). Unlike the mocked + # forwarding test below, this would catch a renamed/reordered core param + # — the core is NOT mocked away. + _seed(barrier_db, "exec-wrap", 3) + out = barrier_pg_decr_and_check( + {"f": 1}, execution_id="exec-wrap", callback_descriptor=_CALLBACK + ) + assert out["status"] == "pending" + remaining, results = _row(barrier_db, "exec-wrap") + assert remaining == 2 + assert results == [{"f": 1}] + + def test_worker_task_forwards_kwargs_verbatim(self): + # Complements the real-row test above by pinning the keyword-forwarding + # contract explicitly: the wrapper passes result + execution_id + + # callback_descriptor straight through, returning the core's result. + sentinel = {"status": "pending", "remaining": 9} + with patch.object( + pg_barrier, "_barrier_pg_decrement", return_value=sentinel + ) as core: + out = barrier_pg_decr_and_check( + {"f": 1}, execution_id="exec-d", callback_descriptor=_CALLBACK + ) + assert out is sentinel + core.assert_called_once_with( + {"f": 1}, execution_id="exec-d", callback_descriptor=_CALLBACK + ) + + def test_open_transaction_on_shared_conn_raises(self): + # The in-body contract is enforced loudly: a caller that enters with an + # already-open transaction on the shared connection is rejected before + # any decrement runs (would otherwise corrupt 'remaining' and hang the + # barrier to expiry). Guards the 2c in-body caller. + import psycopg2.extensions + + conn = MagicMock() + conn.get_transaction_status.return_value = ( + psycopg2.extensions.TRANSACTION_STATUS_INTRANS + ) + with patch.object(pg_barrier, "_get_conn", return_value=conn): + with pytest.raises(RuntimeError, match="own committed transaction"): + _barrier_pg_decrement( + {"f": 1}, execution_id="exec-tx", callback_descriptor=_CALLBACK + ) + conn.cursor.assert_not_called() # rejected before touching the DB + + class TestAbort: def test_claims_and_deletes(self, barrier_db): _seed(barrier_db, "exec-X", 2) From 5f2d18df9ce0842c2c859d621ad3a309af272cbb Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:04:26 +0530 Subject: [PATCH 17/65] =?UTF-8?q?UN-3562=20[FEAT]=20PG=20Queue=209e=20PR?= =?UTF-8?q?=202b=20=E2=80=94=20per-batch=20idempotency=20primitive=20(pg?= =?UTF-8?q?=5Fbatch=5Fdedup=20+=20claim=5Fbatch=20/=20clear=5Fexecution=5F?= =?UTF-8?q?batches)=20(#2068)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3562 [FEAT] PG Queue 9e PR 2b — per-batch idempotency primitive (pg_batch_dedup + claim_batch / clear_execution_batches) Second slice of 9e PR 2, after 2a (#2067). Inert idempotency primitive: the durable per-batch dedup marker 2c wires into the at-least-once PG path. Why: the PG queue is at-least-once, so process_file_batch can be redelivered after a crash-before-ack → re-run batch + double-decrement the barrier (non-idempotent, max_retries=0). Recon showed existing per-file protection is only partial (Redis lock released after write; WFE COMPLETED skips tool re-exec but not necessarily the destination write; FileHistory is cross-execution only), so a durable per-batch gate is needed; per-file status stays the partial-crash backstop. - backend: PgBatchDedup model + migration 0006 — table pg_batch_dedup with a UniqueConstraint(execution_id, batch_index) (the ON CONFLICT target; its execution_id-leading index also serves the cleanup DELETE). Django-managed, extension-free, same posture as the sibling pg_queue models. - workers (pg_barrier.py, reusing the barrier's _cursor() → one PG conn per worker child): claim_batch(execution_id, batch_index) -> bool (atomic INSERT ... ON CONFLICT DO NOTHING RETURNING; True=first/decrement, False=redelivery/skip) + clear_execution_batches(execution_id) -> int (barrier-teardown cleanup; reaper sweep is the backstop). No call-site wiring — claim/clear + batch_index threading + transport switch land in 2c. Inert: new table + two helpers, no callers. Net behaviour: NONE. Tests: +8 real-PG (first-claim, redelivery-rejected, distinct-batch, distinct-exec, concurrent-exactly-one-winner, clear-only-target, clear-empty-zero, reclaim-after-clear). Migration applied to dev DB, makemigrations --check clean, bootstrap clean under WORKER_BARRIER_BACKEND=pg. Co-Authored-By: Claude Opus 4.8 * UN-3562 address review (muhammad-ali-e P1-P8): fix reaper-backstop docstring claim, batch_index constraint, race test, nits - P1 (verified bug): the docstrings claimed the reaper's barrier-orphan sweep reclaims orphaned pg_batch_dedup markers — it doesn't. sweep_expired_barriers DELETEs only pg_barrier_state (no cascade), so orphaned markers leak today. Reworded both PgBatchDedup + clear_execution_batches docstrings to state the leak honestly + flag the dedup-orphan sweep as intended future work. - P4: add CheckConstraint(batch_index >= 0) (writer-proof, mirrors PgQueueMessage.priority) + a test that the DB rejects a negative index. Regenerated migration 0006 to include it. - P6: claim_batch docstring no longer says it decrements; defers the single decrement to the caller (the function only inserts the marker). - P7: generalized "partial per-file protection" → "not fully idempotent on redelivery" so the rationale can't rot. - P5: documented created_at as observability-only (future age-based sweep). - P2: REQUIRE_PG_TESTS env → skip becomes fail, so the idempotency primitive can't ship untested-green in CI where PG is expected. - P3: strengthened the race test — pre-build N=8 conns in the main thread, align claims with threading.Barrier, loop 5 trials (forces the contended ON CONFLICT path instead of a serial fast-path). - P8: hoisted import os to module top. 35 pg_barrier + 9 dedup tests green; migration applied to dev DB, makemigrations --check clean; ruff clean. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../migrations/0006_pgbatchdedup_and_more.py | 47 +++++ backend/pg_queue/models.py | 61 +++++++ workers/queue_backend/pg_barrier.py | 48 +++++ workers/tests/test_pg_batch_dedup.py | 169 ++++++++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 backend/pg_queue/migrations/0006_pgbatchdedup_and_more.py create mode 100644 workers/tests/test_pg_batch_dedup.py diff --git a/backend/pg_queue/migrations/0006_pgbatchdedup_and_more.py b/backend/pg_queue/migrations/0006_pgbatchdedup_and_more.py new file mode 100644 index 0000000000..44ad09e4cb --- /dev/null +++ b/backend/pg_queue/migrations/0006_pgbatchdedup_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.1 on 2026-06-17 09:18 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0005_pgorchestratorlock"), + ] + + operations = [ + migrations.CreateModel( + name="PgBatchDedup", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("execution_id", models.TextField()), + ("batch_index", models.IntegerField()), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ], + options={ + "db_table": "pg_batch_dedup", + }, + ), + migrations.AddConstraint( + model_name="pgbatchdedup", + constraint=models.UniqueConstraint( + fields=("execution_id", "batch_index"), + name="pg_batch_dedup_exec_batch_uniq", + ), + ), + migrations.AddConstraint( + model_name="pgbatchdedup", + constraint=models.CheckConstraint( + check=models.Q(("batch_index__gte", 0)), + name="pg_batch_dedup_batch_index_non_negative", + ), + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index bb086c7783..079508619c 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -114,6 +114,67 @@ class Meta: ] +class PgBatchDedup(models.Model): + """Per-batch idempotency marker for the at-least-once PG execution pipeline. + + The PG queue is at-least-once: a ``process_file_batch`` task can be + redelivered after a crash-before-ack, which would re-run the batch and + double-decrement the barrier (``process_file_batch`` is non-idempotent — + ``max_retries=0``, and the destination push is not fully idempotent on + redelivery). This table is the durable dedup token: one row per + ``(execution_id, batch_index)``. + + The worker claims a batch by inserting its row atomically + (``INSERT … ON CONFLICT DO NOTHING RETURNING``): a fresh insert means + "first delivery — the caller proceeds and performs the single barrier + decrement"; a conflict means "already claimed — a redelivery → caller + skips". Pairing the claim with the decrement is what keeps the barrier + decremented exactly once per batch even though the queue may deliver the + task more than once. Rows for an execution are cleared when its barrier + finalises (``remaining`` → 0, via ``clear_execution_batches``). + + Orphan reclaim — executions that never finalise currently **leak** their + dedup markers: the reaper's ``sweep_expired_barriers`` deletes only the + ``pg_barrier_state`` row (there is no FK / cascade to this table), so the + matching markers are left behind. A dedup-orphan sweep (e.g. by ``created_at`` + age) is intended future work; until then the leak is bounded only by how many + executions never finalise. + + Composite uniqueness on ``(execution_id, batch_index)`` is enforced by a + ``UniqueConstraint`` (Django has no composite primary key here); the worker's + ``ON CONFLICT (execution_id, batch_index)`` targets exactly those columns, + and the constraint's index — ``execution_id`` leading — also serves the + per-execution cleanup ``DELETE``. Managed=True / generated migration, no + DB-side function, extension-free (UN-3533) — same posture as the siblings. + """ + + execution_id = models.TextField() + batch_index = models.IntegerField() + # Observability/debugging only today (when a marker was claimed); not read, + # indexed, or used for reclaim. The intended future dedup-orphan sweep (see + # the class docstring) would sweep by this column's age and add an index then. + created_at = models.DateTimeField(default=timezone.now) + + class Meta: + db_table = "pg_batch_dedup" + constraints = [ + # The claim's ON CONFLICT target and the one writer-proof invariant + # (the worker SQL can't import this model): a batch is claimed at + # most once per execution. + models.UniqueConstraint( + fields=["execution_id", "batch_index"], + name="pg_batch_dedup_exec_batch_uniq", + ), + # batch_index is a non-negative ordinal; a negative value is a + # programming error. Writer-proof, mirroring PgQueueMessage.priority's + # domain-bound CheckConstraint precedent. + models.CheckConstraint( + check=models.Q(batch_index__gte=0), + name="pg_batch_dedup_batch_index_non_negative", + ), + ] + + class PgBarrierState(models.Model): """Per-execution fan-in barrier state for ``PgBarrier`` (the Postgres ``WORKER_BARRIER_BACKEND``). diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 5ed2e7d021..ba477dfd49 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -123,6 +123,54 @@ def _delete_barrier(execution_id: str) -> None: ) +def claim_batch(execution_id: str, batch_index: int) -> bool: + """Claim ``(execution_id, batch_index)`` for processing — the per-batch + idempotency gate for the at-least-once PG pipeline. + + Atomically inserts the dedup marker (``pg_batch_dedup``); returns ``True`` + if THIS call inserted the row (first delivery — the caller should proceed + and perform the single barrier decrement) and ``False`` if the row already + existed (a redelivery — the caller should skip). This function itself only + inserts the marker; pairing it with the caller's decrement is what keeps the + barrier decremented exactly once per batch. ``ON CONFLICT DO NOTHING`` makes + the check-and-set a single statement, so concurrent redeliveries of the same + batch resolve to exactly one ``True`` (the row's existence is the token) with + no race. + + Inert in 9e PR 2b — wired into ``process_file_batch`` (claim at batch start) + in PR 2c, alongside the in-body decrement. The companion + :func:`clear_execution_batches` reclaims the markers at barrier finalise. + """ + with _cursor() as cur: + cur.execute( + "INSERT INTO pg_batch_dedup (execution_id, batch_index, created_at) " + "VALUES (%s, %s, now()) " + "ON CONFLICT (execution_id, batch_index) DO NOTHING " + "RETURNING execution_id", + (execution_id, batch_index), + ) + return cur.fetchone() is not None + + +def clear_execution_batches(execution_id: str) -> int: + """Delete all per-batch dedup markers for an execution; returns the count. + + Called at barrier teardown (``remaining`` → 0) so the markers live exactly + as long as the execution needs them. Executions that never finalise + currently leak their markers — the reaper sweeps the orphaned + ``pg_barrier_state`` row but does NOT yet reclaim ``pg_batch_dedup`` (no + cascade); a dedup-orphan sweep is intended future work (see the + ``PgBatchDedup`` model docstring). Safe to call when no rows exist (returns + 0). Uses the ``(execution_id, batch_index)`` constraint index (``execution_id`` + leading) for the lookup. + + Inert in 9e PR 2b — wired into the barrier-finalise path in PR 2c. + """ + with _cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup WHERE execution_id = %s", (execution_id,)) + return cur.rowcount + + @dataclass(frozen=True, slots=True) class _PgBarrierHandle: """Minimal ``BarrierHandle`` — ``id`` is the execution id (what call sites diff --git a/workers/tests/test_pg_batch_dedup.py b/workers/tests/test_pg_batch_dedup.py new file mode 100644 index 0000000000..78b7a6e894 --- /dev/null +++ b/workers/tests/test_pg_batch_dedup.py @@ -0,0 +1,169 @@ +"""Per-batch idempotency markers (9e PR 2b) — ``claim_batch`` / +``clear_execution_batches`` against the real ``pg_batch_dedup`` table. + +The PG queue is at-least-once, so ``process_file_batch`` can be redelivered; the +marker makes the barrier decrement fire exactly once per ``(execution_id, +batch_index)``. These tests pin the claim's check-and-set semantics (incl. a +concurrent race) and the teardown cleanup. Inert in 2b — PR 2c wires them into +the batch task / barrier-finalise path. + +The fixture mirrors ``test_pg_barrier``'s ``barrier_db``: a real autocommit +connection injected into pg_barrier's thread-local (the helpers live there to +share the one connection per worker child). Skips if Postgres is unreachable or +the 0006 migration is unapplied. +""" + +from __future__ import annotations + +import os + +import psycopg2 +import pytest +from queue_backend import pg_barrier +from queue_backend.pg_barrier import claim_batch, clear_execution_batches +from queue_backend.pg_queue.connection import create_pg_connection + + +def _skip_or_fail(reason: str) -> None: + """Skip when Postgres isn't available — UNLESS ``REQUIRE_PG_TESTS`` is set + (CI where PG is expected), where a skip would silently ship the idempotency + primitive untested, so we fail loudly instead. + """ + if os.getenv("REQUIRE_PG_TESTS"): + pytest.fail(f"REQUIRE_PG_TESTS set but PG unusable: {reason}") + pytest.skip(reason) + + +@pytest.fixture +def dedup_db(): + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + try: + conn = create_pg_connection(env_prefix="TEST_DB_") + except psycopg2.OperationalError as exc: + _skip_or_fail(f"Postgres not reachable: {exc}") + conn.autocommit = True + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('pg_batch_dedup')") + if cur.fetchone()[0] is None: + conn.close() + _skip_or_fail("pg_batch_dedup migration not applied (run backend migrate)") + cur.execute("DELETE FROM pg_batch_dedup") + pg_barrier._local.conn = conn + yield conn + with conn.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + conn.close() + pg_barrier._local.conn = None + + +def _count(conn, execution_id): + with conn.cursor() as cur: + cur.execute( + "SELECT count(*) FROM pg_batch_dedup WHERE execution_id = %s", + (execution_id,), + ) + return cur.fetchone()[0] + + +class TestClaimBatch: + def test_first_claim_succeeds(self, dedup_db): + assert claim_batch("exec-1", 0) is True + assert _count(dedup_db, "exec-1") == 1 + + def test_redelivery_of_same_batch_is_rejected(self, dedup_db): + assert claim_batch("exec-1", 0) is True + # Same (execution_id, batch_index) again = a redelivery → skip. + assert claim_batch("exec-1", 0) is False + assert _count(dedup_db, "exec-1") == 1 # no duplicate row + + def test_different_batch_index_is_a_distinct_claim(self, dedup_db): + assert claim_batch("exec-1", 0) is True + assert claim_batch("exec-1", 1) is True # sibling batch, independent + assert _count(dedup_db, "exec-1") == 2 + + def test_same_batch_index_different_execution_is_distinct(self, dedup_db): + assert claim_batch("exec-1", 0) is True + assert claim_batch("exec-2", 0) is True # the key is the (exec, idx) pair + assert _count(dedup_db, "exec-1") == 1 + assert _count(dedup_db, "exec-2") == 1 + + def test_negative_batch_index_is_rejected_by_db(self, dedup_db): + # batch_index is a non-negative ordinal; the CheckConstraint is + # writer-proof even though claim_batch's callers only pass >= 0. + with pytest.raises(psycopg2.errors.CheckViolation): + claim_batch("exec-neg", -1) + + def test_concurrent_claims_resolve_to_exactly_one_winner(self, dedup_db): + # The real at-least-once guarantee: N redeliveries racing on the SAME + # batch must produce exactly one True (the row's existence is the token). + # To actually force the contended ON CONFLICT path (not let a fast first + # thread connect+commit before the others start), pre-build all N + # connections in the main thread and release every claim simultaneously + # via a threading.Barrier. Repeat over several distinct batches so a flaky + # non-atomic check-then-insert can't pass by luck. Each thread uses its + # own connection (a libpq conn is not thread-safe across threads). + import threading + + n = 8 + conns = [create_pg_connection(env_prefix="TEST_DB_") for _ in range(n)] + for c in conns: + c.autocommit = True + try: + for trial in range(5): + batch_index = trial # a fresh, uncontended-so-far batch each round + gate = threading.Barrier(n) + results: list[bool] = [] + lock = threading.Lock() + + # Bind loop vars as defaults (ruff B023): the threads are created + # and joined within this iteration, but defaults make the capture + # explicit and lint-clean. + def run(conn, bi=batch_index, gate=gate, results=results, lock=lock): + pg_barrier._local.conn = conn + try: + gate.wait() # align all claims at the same instant + won = claim_batch("exec-race", bi) + with lock: + results.append(won) + finally: + pg_barrier._local.conn = None + + threads = [threading.Thread(target=run, args=(c,)) for c in conns] + for t in threads: + t.start() + for t in threads: + t.join() + + assert sum(results) == 1, f"trial {trial}: {sum(results)} winners" + assert _count(dedup_db, "exec-race") == trial + 1 + finally: + for c in conns: + c.close() + + +class TestClearExecutionBatches: + def test_clears_only_the_target_execution(self, dedup_db): + claim_batch("exec-keep", 0) + claim_batch("exec-drop", 0) + claim_batch("exec-drop", 1) + + removed = clear_execution_batches("exec-drop") + + assert removed == 2 # both of exec-drop's markers + assert _count(dedup_db, "exec-drop") == 0 + assert _count(dedup_db, "exec-keep") == 1 # untouched + + def test_clear_with_no_rows_returns_zero(self, dedup_db): + assert clear_execution_batches("never-claimed") == 0 + + def test_cleared_batch_can_be_reclaimed(self, dedup_db): + # After teardown clears the markers, a fresh execution reusing the same + # id (or a re-run) can claim again — the gate is per live execution. + assert claim_batch("exec-1", 0) is True + assert claim_batch("exec-1", 0) is False + clear_execution_batches("exec-1") + assert claim_batch("exec-1", 0) is True # claimable again post-cleanup + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From b3f25d9e74236f3603f5f9e271945ea8286de66e Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:56:56 +0530 Subject: [PATCH 18/65] =?UTF-8?q?UN-3563=20[FEAT]=20PG=20Queue=209e=20PR?= =?UTF-8?q?=202c=20=E2=80=94=20live=20PG=20fan-out/barrier/callback=20(fir?= =?UTF-8?q?e-and-forget)=20(#2069)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3563 [FEAT] PG Queue 9e PR 2c — live PG fan-out/barrier/callback (fire-and-forget) Wires the coupled pipeline's fan-out → barrier → callback onto the PG queue for a transport=="pg_queue" execution. Gated: resolve_transport() still returns celery (PR3 Flipt flips it), so the whole PG branch is present-but-unreachable — default path byte-identical. Orchestrator task (async_execute_bin) stays on Celery (hybrid); routing it onto PG is a 2d follow-up. - barrier.py: Barrier Protocol + CeleryChordBarrier/RedisDecrBarrier accept (and ignore) a `transport` param; CallbackDescriptor gains an optional `backend`. - orchestration_utils._barrier_for_transport: pg_queue → fresh PgBarrier() (bypasses the WORKER_BARRIER_BACKEND singleton), else the singleton. - pg_barrier.PgBarrier.enqueue(transport): pg_queue → fire-and-forget mode — _dispatch_header_pg sends each header via dispatch(backend=PG) with an injected _barrier_context {execution_id, batch_index, callback_descriptor}, no .link; descriptor marked backend=pg_queue; UPSERT block also clears pg_batch_dedup (greptile #2068 reuse-reset). _fire_barrier_callback self-chains the callback onto PG when backend==pg_queue. clear_execution_batches at finalise + abort. run_batch_with_barrier(): claim → work → in-body _barrier_pg_decrement; redelivery skips; exception → barrier_pg_abort. - file_processing.process_file_batch(_barrier_context=None): core routes None → _run_batch_stages (celery chord path), else → run_batch_with_barrier. - general/api fan-outs thread transport into create_chord_execution. Tests: +8 PgBarrier fire-and-forget + 2 orchestration routing + 2 process_file_batch routing. Each test file green alone; ruff clean. End-to-end forced-pg dev-test pending before PR. Co-Authored-By: Claude Opus 4.8 * UN-3563 fix SonarCloud S1172: drop unused task_instance from _run_batch_stages The extracted _run_batch_stages never uses task_instance — its only purpose (deriving celery_task_id) happens in _process_file_batch_core before the call. Removed the param + updated both call sites. _process_file_batch_core keeps task_instance (it reads .request.id). Routing test mocks with *a, unaffected. Co-Authored-By: Claude Opus 4.8 * UN-3563 address review (muhammad-ali-e, 15): strand-on-failure hardening + typing/dedup/docs/tests Decision (with reviewer): reaper-as-safety-net for the un-catchable strand windows + fix what's catchable + document + gate on PR3. Failure handling: - [#69 Critical] run_batch_with_barrier wraps BOTH work + decrement in the abort: a decrement-side failure (guard / DB / last-batch callback dispatch) tears the barrier down in-body instead of stranding to expiry. - [#79] extracted _abort_barrier_in_body — logs when the teardown itself fails (was silently suppressed under a misleading "torn down" message). - [#74/#81] documented the two un-catchable strand windows (hard-crash-during-work, post-commit callback-dispatch-fail) as a HARD reaper dependency for PR3. - [#86] finalise cleanup split into independent try/excepts with distinct logs. Typing / clarity: - [#01] BarrierContext(TypedDict) for _barrier_context (header fan-out, run_batch_with_barrier, process_file_batch). - [#03] renamed CallbackDescriptor "backend" -> "transport" (WorkflowTransport value; avoids the QueueBackend "pg" collision). - [#27] is_pg_transport() predicate in core; used in orchestration_utils + pg_barrier. - [#20] extracted _dispatch_pg() — single home for cycle-avoiding local import + backend=PG. - [#35] normalize_transport() at the general worker entry (parity w/ api/scheduler). - [#94] log when a header has no queue option. - [#09/#13] fixed born-stale comment + kwargs-not-args docstring. Tests (+#37/#41): last-batch self-chains callback to PG + cleans up barrier/dedup; decrement-failure aborts; PG-branch mid-loop dispatch-failure deletes row; header args/queue/pre-existing-kwargs preservation. 137 barrier/dedup/routing tests green; bootstrap clean under WORKER_BARRIER_BACKEND=pg. Co-Authored-By: Claude Opus 4.8 * UN-3563 fix run_batch_with_barrier strand-window doc inconsistency (review) The second "NOT catchable" bullet conflated two different things: it described the in-body catchable abort ("the abort here removes the row") and a *software* callback-dispatch failure — but that failure is already caught + torn down by step 3's wrap (paragraph 1), so it doesn't belong under the un-catchable heading, and on the PG path _fire_barrier_callback IS the enqueue so "committed but before the enqueue" couldn't both hold. Rewrote the bullet to the genuinely un-catchable window: a hard crash BETWEEN the decrement committing (remaining→0) and the callback enqueue completing — decrement committed (redelivery blocked by the marker), process gone before the callback enqueues or any abort runs, row survives to expiry, reaper-only recovery. Explicitly notes a software dispatch failure is the catchable case. Keeps this list an accurate spec for the PR-3 reaper-recovery dependency. Co-Authored-By: Claude Opus 4.8 * UN-3563 address greptile (#2069, 2): clear dedup on mid-loop PG failure + carry fairness on PG callback Both in the gated PG path (greptile 4/5, safe to merge). - Issue 1: PgBarrier.enqueue mid-loop dispatch-failure handler now also calls clear_execution_batches on the PG path. Earlier headers may have committed a claim_batch marker; with the barrier row deleted, their in-flight barrier_pg_abort is a no-op (already_aborted) and never reaches the clear inside it, so reclaim the markers directly here. - Issue 2: the PG callback now carries the producer's fairness. Added _fairness_from_headers() to reconstruct the FairnessKey from the stored x-fairness-key headers and pass it to _dispatch_pg, so the callback rides the same org/priority as the Celery path (was always default priority). Tests: +fairness-carried / +fairness-none-safe on _fire_barrier_callback; extended the PG mid-loop test to assert an already-claimed marker is reclaimed. 75 barrier/dedup tests green; bootstrap clean under WORKER_BARRIER_BACKEND=pg. Co-Authored-By: Claude Opus 4.8 * UN-3563 fix SonarCloud S3776: reduce PgBarrier.enqueue cognitive complexity (17→under 15) Extracted the per-header dispatch loop into PgBarrier._dispatch_headers — the deeply-nested for→try/except→if/else→if (PG-vs-celery branch + mid-loop failure teardown + PG dedup-clear) was the complexity driver. enqueue now calls the helper; behaviour identical. radon: enqueue C(11)→B(6); ruff C901 passes. 75 barrier/dedup tests green; ruff + ruff-format clean. Co-Authored-By: Claude Opus 4.8 * UN-3563 fix greptile #2069: mid-loop dedup-clear test passed for the wrong reason The pre-seeded claim_batch marker was wiped by enqueue's UPSERT block (the reuse-reset DELETE) before the dispatch loop, so the mid-loop clear_execution_batches deleted 0 rows — the count==0 assertion passed on the UPSERT, not the guard under test. Now the first dispatch side-effect claims the marker AFTER the UPSERT (simulating a fast PG consumer), so the mid-loop clear is what removes it. Verified: with the clear disabled the marker orphans (count=1). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../core/src/unstract/core/data_models.py | 11 + workers/api-deployment/tasks.py | 17 +- workers/file_processing/tasks.py | 77 ++- workers/general/tasks.py | 10 + workers/queue_backend/barrier.py | 35 +- workers/queue_backend/pg_barrier.py | 437 +++++++++++++++--- workers/queue_backend/redis_barrier.py | 8 +- .../workflow/execution/orchestration_utils.py | 30 +- workers/tests/test_barrier.py | 36 ++ workers/tests/test_chord_callback_boundary.py | 40 ++ workers/tests/test_pg_barrier.py | 286 ++++++++++++ 11 files changed, 894 insertions(+), 93 deletions(-) diff --git a/unstract/core/src/unstract/core/data_models.py b/unstract/core/src/unstract/core/data_models.py index 0336647570..20e3d0c3aa 100644 --- a/unstract/core/src/unstract/core/data_models.py +++ b/unstract/core/src/unstract/core/data_models.py @@ -249,6 +249,17 @@ def normalize_transport(value: object, *, logger: Any = None, context: str = "") return DEFAULT_WORKFLOW_TRANSPORT +def is_pg_transport(transport: str | None) -> bool: + """True if ``transport`` is the Postgres-queue transport. + + Single source for "what counts as PG transport" — centralises the + ``== WorkflowTransport.PG_QUEUE.value`` comparison scattered across the + worker fan-out / barrier code, and the seam to extend if a second + PG-family transport is ever added. + """ + return transport == WorkflowTransport.PG_QUEUE.value + + class FileListingResult: """Result of listing files from a source.""" diff --git a/workers/api-deployment/tasks.py b/workers/api-deployment/tasks.py index f4bec94a73..1aafe70bad 100644 --- a/workers/api-deployment/tasks.py +++ b/workers/api-deployment/tasks.py @@ -26,7 +26,13 @@ from shared.workflow.execution.tool_validation import validate_workflow_tool_instances from worker import app -from unstract.core.data_models import ExecutionStatus, FileHashData, WorkerFileData +from unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, + ExecutionStatus, + FileHashData, + WorkerFileData, + normalize_transport, +) from unstract.core.worker_models import ApiDeploymentResultStatus logger = WorkerLogger.get_logger(__name__) @@ -683,6 +689,14 @@ def _run_workflow_api( org_id=str(schema_name), workload_type=WorkloadType.API, ) + # Transport rides in via the dispatched task's kwargs (PR 1 seam); the + # fan-out honours it (PG path → fire-and-forget PgBarrier). Fail-closed + # to celery on any unrecognised value. + transport = normalize_transport( + kwargs.get("transport", DEFAULT_WORKFLOW_TRANSPORT), + logger=logger, + context=f" [exec:{execution_id}]", + ) result = WorkflowOrchestrationUtils.create_chord_execution( batch_tasks=batch_tasks, callback_task_name="process_batch_callback_api", @@ -694,6 +708,7 @@ def _run_workflow_api( callback_queue=file_processing_callback_queue, app_instance=app, fairness=api_fairness, + transport=transport, ) if result is None: diff --git a/workers/file_processing/tasks.py b/workers/file_processing/tasks.py index 3aa93296e7..5afb41ab54 100644 --- a/workers/file_processing/tasks.py +++ b/workers/file_processing/tasks.py @@ -11,6 +11,8 @@ from typing import Any from queue_backend import worker_task +from queue_backend.barrier import BarrierContext +from queue_backend.pg_barrier import run_batch_with_barrier # Import shared worker infrastructure from shared.api import InternalAPIClient @@ -222,25 +224,16 @@ def _enhance_batch_with_mrq_flags( ) -def _process_file_batch_core( - task_instance, file_batch_data: dict[str, Any] +def _run_batch_stages( + file_batch_data: dict[str, Any], celery_task_id: str ) -> dict[str, Any]: - """Core implementation of file batch processing. - - This function contains the actual processing logic that both the new task - and Django compatibility task will use. + """The actual batch work (validate → setup → pre-create → process → compile). - Args: - task_instance: The Celery task instance (self) - file_batch_data: Dictionary that will be converted to FileBatchData dataclass - - Returns: - Dictionary with successful_files and failed_files counts + Transport-agnostic: identical on the Celery chord path and the PG + fire-and-forget path. The task instance isn't needed here — its only use + (deriving ``celery_task_id``) happens in the caller. Returns the + JSON-serialisable batch result. """ - celery_task_id = ( - task_instance.request.id if hasattr(task_instance, "request") else "unknown" - ) - # Step 1: Validate and parse input data batch_data = _validate_and_parse_batch_data(file_batch_data) @@ -260,6 +253,44 @@ def _process_file_batch_core( return _compile_batch_result(context) +def _process_file_batch_core( + task_instance, + file_batch_data: dict[str, Any], + barrier_context: BarrierContext | None = None, +) -> dict[str, Any]: + """Core implementation of file batch processing. + + This function contains the actual processing logic that both the new task + and Django compatibility task will use. + + Args: + task_instance: The Celery task instance (self) + file_batch_data: Dictionary that will be converted to FileBatchData dataclass + barrier_context: Present only on the 9e PG fire-and-forget path — carries + ``execution_id`` / ``batch_index`` / ``callback_descriptor`` so the + batch claims its slot and runs the barrier decrement in-body (a + PG-consumed task fires no Celery ``.link``). ``None`` on the Celery + chord path, where the chord ``.link`` drives the decrement instead. + + Returns: + Dictionary with successful_files and failed_files counts + """ + celery_task_id = ( + task_instance.request.id if hasattr(task_instance, "request") else "unknown" + ) + + if barrier_context is None: + # Celery chord path — the chord's .link runs the decrement after this. + return _run_batch_stages(file_batch_data, celery_task_id) + + # PG fire-and-forget path — claim the batch (idempotent on redelivery), run + # the stages, then decrement the barrier in-body / self-chain the callback. + return run_batch_with_barrier( + barrier_context, + lambda: _run_batch_stages(file_batch_data, celery_task_id), + ) + + @worker_task( bind=True, name=TaskName.PROCESS_FILE_BATCH, @@ -272,18 +303,26 @@ def _process_file_batch_core( # Timeout inherited from global Celery config (FILE_PROCESSING_TASK_TIME_LIMIT env var) ) @monitor_performance -def process_file_batch(self, file_batch_data: dict[str, Any]) -> dict[str, Any]: - """Process a batch of files in parallel using Celery. +def process_file_batch( + self, + file_batch_data: dict[str, Any], + _barrier_context: BarrierContext | None = None, +) -> dict[str, Any]: + """Process a batch of files in parallel. This is the main task entry point for new workers. Args: file_batch_data: Dictionary that will be converted to FileBatchData dataclass + _barrier_context: Injected only when this task is dispatched onto the PG + queue (9e fire-and-forget path) by ``PgBarrier`` — carries the barrier + coordination context (``execution_id`` / ``batch_index`` / + ``callback_descriptor``). Absent on the Celery chord path. Returns: Dictionary with successful_files and failed_files counts """ - return _process_file_batch_core(self, file_batch_data) + return _process_file_batch_core(self, file_batch_data, _barrier_context) def _validate_and_parse_batch_data(file_batch_data: dict[str, Any]) -> FileBatchData: diff --git a/workers/general/tasks.py b/workers/general/tasks.py index 8d9e6e4bf4..1986a0e0cf 100644 --- a/workers/general/tasks.py +++ b/workers/general/tasks.py @@ -56,6 +56,7 @@ FileBatchData, FileHashData, WorkerFileData, + normalize_transport, ) # Import common workflow utilities @@ -488,6 +489,12 @@ def _execute_general_workflow( """ start_time = time.time() + # Fail-closed coercion, for parity with the api/scheduler workers (which run + # normalize_transport at their entry): a typo'd transport degrades to Celery + # with a warning rather than silently routing onto an unknown substrate. The + # coerced value feeds both WorkflowContextData and the fan-out below. + transport = normalize_transport(transport, logger=logger) + logger.info("Executing general workflow logic for ETL/TASK workflow") try: @@ -710,6 +717,7 @@ def _execute_general_workflow( execution_mode=execution_mode, use_file_history=use_file_history, organization_id=api_client.organization_id, + transport=transport, **kwargs, ) @@ -764,6 +772,7 @@ def _orchestrate_file_processing_general( execution_mode: tuple | None, use_file_history: bool, organization_id: str, + transport: str = DEFAULT_WORKFLOW_TRANSPORT, **kwargs: dict[str, Any], ) -> dict[str, Any]: """Orchestrate file processing for general workflows using the same pattern as API worker. @@ -957,6 +966,7 @@ def _orchestrate_file_processing_general( org_id=organization_id, workload_type=WorkloadType.NON_API, ), + transport=transport, ) if not result: diff --git a/workers/queue_backend/barrier.py b/workers/queue_backend/barrier.py index d56348ce42..0e838242ca 100644 --- a/workers/queue_backend/barrier.py +++ b/workers/queue_backend/barrier.py @@ -36,10 +36,12 @@ import logging import os -from typing import TYPE_CHECKING, Any, Protocol, TypedDict +from typing import TYPE_CHECKING, Any, NotRequired, Protocol, TypedDict from celery import chord +from unstract.core.data_models import DEFAULT_WORKFLOW_TRANSPORT + from .fairness import FairnessKey from .handle import BarrierHandle @@ -92,6 +94,29 @@ class CallbackDescriptor(TypedDict): kwargs: dict[str, Any] queue: str fairness_headers: dict[str, Any] | None + # 9e: the WorkflowTransport the aggregating callback is fired on when the + # barrier completes. Absent / ``None`` → legacy Celery dispatch + # (``current_app.apply_async`` — the ``.link`` path). ``"pg_queue"`` → the + # fire-and-forget PG path self-chains the callback via dispatch onto PG. + # Named ``transport`` (a WorkflowTransport value, e.g. ``"pg_queue"``) — NOT + # ``backend``, to avoid confusion with ``QueueBackend`` (``"pg"``). + transport: NotRequired[str | None] + + +class BarrierContext(TypedDict): + """Per-batch barrier coordination injected into a PG-dispatched header task. + + The 9e fire-and-forget sibling of :class:`CallbackDescriptor`, and typed for + the same reason: it crosses producer → PG-queue → consumer, so the contract + is pinned here to catch a typo/rename at the type layer rather than as a + remote ``KeyError`` mid-batch. Carried as the ``_barrier_context`` kwarg on + ``process_file_batch`` so the consumer can claim its slot and run the barrier + decrement in-body (a PG-consumed task fires no Celery ``.link``). + """ + + execution_id: str + batch_index: int + callback_descriptor: CallbackDescriptor class Barrier(Protocol): @@ -112,9 +137,15 @@ def enqueue( callback_queue: str, app_instance: Any, fairness: FairnessKey | None = None, + transport: str = DEFAULT_WORKFLOW_TRANSPORT, ) -> BarrierHandle | None: """Enqueue ``header_tasks`` and a single callback to fire on completion. + ``transport`` is the per-execution transport (9e). Only ``PgBarrier`` + acts on it (``pg_queue`` → fire-and-forget PG fan-out instead of Celery + ``.link``); the Celery/Redis substrates accept it for Protocol parity + and ignore it (they are only ever reached on the ``celery`` transport). + ``None`` is the **sole signal** that no work was enqueued — it is returned exclusively when ``header_tasks`` is empty. Any substrate-level failure (broker outage, serialisation error, @@ -166,8 +197,10 @@ def enqueue( callback_queue: str, app_instance: Any, fairness: FairnessKey | None = None, + transport: str = DEFAULT_WORKFLOW_TRANSPORT, ) -> BarrierHandle | None: """See :class:`Barrier.enqueue`.""" + del transport # Celery chord is only reached on the celery transport. # Empty-header guard goes FIRST so a zero-task run skips # signature construction / fairness serialisation entirely. # Any failure in those paths is now constrained to the diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index ba477dfd49..956ce09a05 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -60,16 +60,27 @@ import json import logging import threading -from collections.abc import Iterator +from collections.abc import Callable, Iterator from dataclasses import dataclass from typing import TYPE_CHECKING, Any import psycopg2 import psycopg2.extensions -from .barrier import CallbackDescriptor, barrier_ttl_seconds +from unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, + WorkflowTransport, + is_pg_transport, +) + +from .barrier import BarrierContext, CallbackDescriptor, barrier_ttl_seconds from .decorator import worker_task -from .fairness import FairnessKey +from .fairness import ( + DEFAULT_PRIORITY, + FAIRNESS_HEADER_NAME, + FairnessKey, + WorkloadType, +) from .handle import BarrierHandle from .pg_queue.connection import create_pg_connection @@ -137,8 +148,8 @@ def claim_batch(execution_id: str, batch_index: int) -> bool: batch resolve to exactly one ``True`` (the row's existence is the token) with no race. - Inert in 9e PR 2b — wired into ``process_file_batch`` (claim at batch start) - in PR 2c, alongside the in-body decrement. The companion + Called on the in-body PG path from :func:`run_batch_with_barrier` (claim at + batch start), alongside the in-body decrement. The companion :func:`clear_execution_batches` reclaims the markers at barrier finalise. """ with _cursor() as cur: @@ -164,13 +175,45 @@ def clear_execution_batches(execution_id: str) -> int: 0). Uses the ``(execution_id, batch_index)`` constraint index (``execution_id`` leading) for the lookup. - Inert in 9e PR 2b — wired into the barrier-finalise path in PR 2c. + Called from the barrier-finalise + abort paths. """ with _cursor() as cur: cur.execute("DELETE FROM pg_batch_dedup WHERE execution_id = %s", (execution_id,)) return cur.rowcount +def _dispatch_pg( + task_name: str, + *, + args: list[Any] | None = None, + kwargs: dict[str, Any] | None = None, + queue: str | None, + fairness: FairnessKey | None = None, +) -> Any: + """Enqueue a task onto the PG queue (the one place that owns the cycle-avoiding + local imports + the ``backend=QueueBackend.PG`` argument). + + Both the header fan-out (:meth:`PgBarrier._dispatch_header_pg`) and the + self-chained callback (:func:`_fire_barrier_callback`) route through here. + Returns the ``dispatch`` handle. ``queue`` is required (may be ``None`` only + if the caller has already logged the fallback) — a ``None`` queue makes + ``dispatch`` fall back to its default PG queue. + """ + # Local import: dispatch/routing pull in queue plumbing that imports the + # barrier package — importing at module load would be a cycle. + from .dispatch import dispatch + from .routing import QueueBackend + + return dispatch( + task_name, + args=args, + kwargs=kwargs, + queue=queue, + fairness=fairness, + backend=QueueBackend.PG, + ) + + @dataclass(frozen=True, slots=True) class _PgBarrierHandle: """Minimal ``BarrierHandle`` — ``id`` is the execution id (what call sites @@ -197,15 +240,26 @@ def enqueue( callback_queue: str, app_instance: Any, fairness: FairnessKey | None = None, + transport: str = DEFAULT_WORKFLOW_TRANSPORT, ) -> BarrierHandle | None: """See :class:`queue_backend.barrier.Barrier.enqueue`. Empty ``header_tasks`` → ``None`` (caller owns the zero-files contract); any substrate failure raises. ``app_instance`` is accepted for Protocol - parity but unused — the callback is built inside the link task via - ``current_app`` so it runs against the worker's app. + parity but unused. + + ``transport`` selects the fan-out mode (9e): + + - ``celery`` (default, the ``WORKER_BARRIER_BACKEND=pg`` legacy path): + header tasks are Celery-dispatched with ``.link(barrier_pg_decr_and_check)`` + / ``.link_error(barrier_pg_abort)``; the link drives the decrement. + - ``pg_queue`` (the fire-and-forget path): header tasks are dispatched onto + the PG queue (``dispatch(backend=PG)``) with a ``_barrier_context`` kwarg + carrying ``execution_id`` / ``batch_index`` / ``callback_descriptor`` — + a PG-consumed task fires no ``.link``, so it claims its batch and runs + the decrement in-body, self-chaining the callback at ``remaining`` → 0. """ - del app_instance # Protocol parity; callback built in the link task. + del app_instance # Protocol parity; callback dispatched by the decrement. if not header_tasks: logger.info( f"[exec:{callback_kwargs.get('execution_id')}] " @@ -223,6 +277,7 @@ def enqueue( ) execution_id = str(execution_id) + is_pg = is_pg_transport(transport) try: fairness_headers = fairness.as_header() if fairness else None callback_descriptor: CallbackDescriptor = { @@ -231,6 +286,10 @@ def enqueue( "queue": callback_queue, "fairness_headers": fairness_headers, } + if is_pg: + # The decrement (run in-body on the PG path) reads this to + # self-chain the callback onto PG rather than Celery. + callback_descriptor["transport"] = WorkflowTransport.PG_QUEUE.value ttl_seconds = barrier_ttl_seconds() with _cursor() as cur: @@ -249,38 +308,29 @@ def enqueue( " created_at = now(), expires_at = EXCLUDED.expires_at", (execution_id, len(header_tasks), ttl_seconds), ) + # Reset per-batch dedup markers from a prior run reusing this + # execution_id, ATOMICALLY with the barrier reset. Without this a + # re-enqueue would leave stale markers → every in-body claim_batch + # returns False → all batches skip → barrier hangs to expiry. + # (Markers are written by claim_batch() on the in-body PG path.) + cur.execute( + "DELETE FROM pg_batch_dedup WHERE execution_id = %s", + (execution_id,), + ) - link_signature = barrier_pg_decr_and_check.s( + self._dispatch_headers( + header_tasks, + is_pg=is_pg, execution_id=execution_id, callback_descriptor=callback_descriptor, + fairness=fairness, + fairness_headers=fairness_headers, ) - link_error_signature = barrier_pg_abort.s(execution_id=execution_id) - for i, task in enumerate(header_tasks): - try: - cloned = task.clone() - if fairness_headers: - cloned.set(headers=fairness_headers) - cloned.link(link_signature) - cloned.link_error(link_error_signature) - cloned.apply_async() - except Exception: - # Mid-loop dispatch failure: i of N never reached the broker, - # so the counter can't reach 0. Delete the row so in-flight - # links' decrement finds no row and cleans up; re-raise so the - # caller marks the workflow ERROR. - with contextlib.suppress(Exception): - _delete_barrier(execution_id) - logger.exception( - f"[exec:{execution_id}] apply_async failed at task " - f"{i}/{len(header_tasks)}; barrier row deleted to prevent " - f"spurious callback fires from the orphan tasks' links." - ) - raise logger.info( - f"Barrier enqueued via PgBarrier — exec_id={execution_id}, " - f"header_tasks={len(header_tasks)}, callback={callback_task_name}, " - f"queue={callback_queue}" + f"Barrier enqueued via PgBarrier ({transport}) — " + f"exec_id={execution_id}, header_tasks={len(header_tasks)}, " + f"callback={callback_task_name}, queue={callback_queue}" ) return _PgBarrierHandle(id=execution_id) @@ -294,6 +344,163 @@ def enqueue( ) raise + def _dispatch_headers( + self, + header_tasks: list[Signature], + *, + is_pg: bool, + execution_id: str, + callback_descriptor: CallbackDescriptor, + fairness: FairnessKey | None, + fairness_headers: dict[str, Any] | None, + ) -> None: + """Dispatch the N header tasks, one transport or the other. + + PG path (``is_pg``) → fire-and-forget onto the PG queue via + :meth:`_dispatch_header_pg` (no ``.link``). Celery path → ``.link`` / + ``.link_error`` chord-style. On any mid-loop dispatch failure, ``i`` of N + never reached the queue so the counter can't reach 0 — delete the barrier + row (and, on the PG path, reclaim dedup markers an earlier header may have + committed, since the in-flight ``barrier_pg_abort`` is a no-op once the row + is gone) so an in-flight decrement finds nothing, then re-raise. + """ + link_signature = barrier_pg_decr_and_check.s( + execution_id=execution_id, callback_descriptor=callback_descriptor + ) + link_error_signature = barrier_pg_abort.s(execution_id=execution_id) + for i, task in enumerate(header_tasks): + try: + if is_pg: + self._dispatch_header_pg( + task, i, execution_id, callback_descriptor, fairness + ) + else: + cloned = task.clone() + if fairness_headers: + cloned.set(headers=fairness_headers) + cloned.link(link_signature) + cloned.link_error(link_error_signature) + cloned.apply_async() + except Exception: + with contextlib.suppress(Exception): + _delete_barrier(execution_id) + if is_pg: + with contextlib.suppress(Exception): + clear_execution_batches(execution_id) + logger.exception( + f"[exec:{execution_id}] header dispatch failed at task " + f"{i}/{len(header_tasks)}; barrier row deleted to prevent " + f"spurious callback fires from the orphan tasks." + ) + raise + + @staticmethod + def _dispatch_header_pg( + task: Signature, + batch_index: int, + execution_id: str, + callback_descriptor: CallbackDescriptor, + fairness: FairnessKey | None, + ) -> None: + """Dispatch one header task onto the PG queue (fire-and-forget mode). + + Unpacks the Celery ``Signature`` (the fan-out built it as + ``app.signature(name, kwargs={batch_files, batch_index, total_batches}, + queue=...)`` — the batch payload is in ``kwargs``) and re-dispatches it via + :func:`_dispatch_pg` with an added ``_barrier_context`` kwarg. The PG + consumer runs the task; it claims ``(execution_id, batch_index)`` and runs + the decrement in-body (no ``.link``). ``fairness`` carries org/priority + onto the row exactly as the bare-dispatch sites do. + """ + barrier_context: BarrierContext = { + "execution_id": execution_id, + "batch_index": batch_index, + "callback_descriptor": callback_descriptor, + } + header_kwargs = dict(task.kwargs or {}) + header_kwargs["_barrier_context"] = barrier_context + queue = task.options.get("queue") + if queue is None: + # The fan-out always sets a queue; a missing one would silently route + # to dispatch's default PG queue (off the intended file-processing + # queue), so surface it rather than let it slip by. + logger.warning( + f"[exec:{execution_id}] header task {task.task!r} (batch " + f"{batch_index}) has no queue option — falling back to the default " + f"PG queue; the consumer for the intended queue won't see it." + ) + _dispatch_pg( + task.task, + args=list(task.args or ()), + kwargs=header_kwargs, + queue=queue, + fairness=fairness, + ) + + +def _fairness_from_headers( + fairness_headers: dict[str, Any] | None, +) -> FairnessKey | None: + """Reconstruct a :class:`FairnessKey` from the stored ``x-fairness-key`` header. + + The descriptor carries the wire-shape headers (``FairnessKey.as_header()``), + but the PG dispatch path wants the ``FairnessKey`` itself (``dispatch`` writes + ``org_id`` + ``pipeline_priority`` onto the row). Reconstructing keeps the PG + callback at the producer's org/priority — parity with the Celery path, which + applies the headers directly. ``None`` when the producer attached no key. + """ + payload = (fairness_headers or {}).get(FAIRNESS_HEADER_NAME) + if not payload: + return None + return FairnessKey( + org_id=payload.get("org_id"), + workload_type=WorkloadType(payload["workload_type"]), + pipeline_priority=payload.get("pipeline_priority", DEFAULT_PRIORITY), + ) + + +def _fire_barrier_callback( + callback_descriptor: CallbackDescriptor, all_results: list[Any] +) -> str: + """Dispatch the aggregating callback when the barrier completes; return its id. + + Two transports, selected by the descriptor's ``transport`` marker: + + - ``"pg_queue"`` (9e fire-and-forget PG path): self-chain the callback onto + the PG queue via :func:`_dispatch_pg`, carrying the producer's fairness + (reconstructed from the stored headers) so the callback rides the same + org/priority as the Celery path — no Celery, so the whole execution stays + off the broker. + - absent / anything else (legacy ``.link`` path): dispatch via + ``current_app.signature(...).apply_async()`` — byte-identical to pre-9e, + preserving the ``fairness_headers`` the producer attached. + """ + if is_pg_transport(callback_descriptor.get("transport")): + handle = _dispatch_pg( + callback_descriptor["task_name"], + args=[all_results], + kwargs=callback_descriptor["kwargs"], + queue=callback_descriptor["queue"], + fairness=_fairness_from_headers(callback_descriptor.get("fairness_headers")), + ) + return str(handle.id) + + from celery import current_app + + # Build the callback-signature kwargs explicitly (headers only when truthy) — + # matching CeleryChordBarrier's idiom, no spurious headers=None. + signature_kwargs: dict[str, Any] = { + "args": [all_results], + "kwargs": callback_descriptor["kwargs"], + "queue": callback_descriptor["queue"], + } + if callback_descriptor.get("fairness_headers"): + signature_kwargs["headers"] = callback_descriptor["fairness_headers"] + callback_signature = current_app.signature( + callback_descriptor["task_name"], **signature_kwargs + ) + return str(callback_signature.apply_async().id) + def _barrier_pg_decrement( result: Any, @@ -321,8 +528,6 @@ def _barrier_pg_decrement( mid-transaction (see the guard below). The Celery wrapper additionally pins ``max_retries=0``; an orphaned barrier is bounded by ``expires_at`` instead. """ - from celery import current_app - # Enforce the "own committed transaction" contract loudly. A caller that # invokes this inside an already-open transaction on the shared thread-local # connection would hold the row lock across the call and let the outer txn's @@ -406,47 +611,46 @@ def _barrier_pg_decrement( return {"status": "abandoned", "remaining": remaining} # remaining == 0: we are the last task. psycopg2 decodes the jsonb array to - # a Python list already — no per-element json.loads needed. Build the kwargs - # explicitly (headers only when truthy) — clearer than an inline ** spread, - # matching CeleryChordBarrier's idiom. - signature_kwargs: dict[str, Any] = { - "args": [all_results], - "kwargs": callback_descriptor["kwargs"], - "queue": callback_descriptor["queue"], - } - if callback_descriptor.get("fairness_headers"): - signature_kwargs["headers"] = callback_descriptor["fairness_headers"] - callback_signature = current_app.signature( - callback_descriptor["task_name"], **signature_kwargs - ) - # Dispatch FIRST; delete the row only after dispatch succeeds, so a callback - # apply_async failure leaves the row (and its expiry) in place rather than - # stranding the execution with no state and no recovery path. - callback_result = callback_signature.apply_async() - # The post-dispatch delete must not mask the successful dispatch: the callback - # already fired, so a delete error here is logged (not raised) — the row - # lingers until expiry rather than re-running the callback. No double-fire is - # possible: this is the last decrement (remaining hit 0) and max_retries=0, so - # the link task is never replayed. + # a Python list already — no per-element json.loads needed. + # + # Dispatch the callback FIRST; delete the row only after dispatch succeeds, so + # a callback dispatch failure leaves the row (and its expiry) in place rather + # than stranding the execution with no state and no recovery path. No + # double-fire is possible: this is the last decrement (remaining hit 0) and + # max_retries=0, so the task is never replayed. + callback_id = _fire_barrier_callback(callback_descriptor, all_results) + # Post-dispatch cleanup is best-effort: the callback already fired, so a + # failure here is logged (not raised) — the rows linger until reclaim rather + # than re-running the callback. The two cleanups are independent so one + # failing doesn't skip the other and each names what failed: the barrier row + # is reclaimed by expiry, the dedup markers by the (PR-3-blocking) dedup-orphan + # sweep — neither re-runs anything (this is the last decrement, max_retries=0). try: _delete_barrier(execution_id) except Exception: logger.exception( f"[exec:{execution_id}] Barrier callback dispatched " - f"(callback_task_id={callback_result.id}) but the post-dispatch row " - f"delete failed — row will be reclaimed at expiry. Callback NOT " - f"re-run (max_retries=0)." + f"(callback_task_id={callback_id}) but deleting the pg_barrier_state " + f"row failed — reclaimed at expiry. Callback NOT re-run." + ) + try: + clear_execution_batches(execution_id) + except Exception: + logger.exception( + f"[exec:{execution_id}] Barrier callback dispatched " + f"(callback_task_id={callback_id}) but clearing pg_batch_dedup markers " + f"failed — reclaimed by the dedup-orphan sweep. Callback NOT re-run." ) logger.info( f"[exec:{execution_id}] Barrier complete — fired callback " f"{callback_descriptor['task_name']} on {callback_descriptor['queue']} " f"with {len(all_results)} aggregated results " - f"(callback_task_id={callback_result.id})" + f"(callback_task_id={callback_id})" ) return { "status": "complete", - "callback_task_id": callback_result.id, + "callback_task_id": callback_id, "aggregated_count": len(all_results), } @@ -510,8 +714,113 @@ def barrier_pg_abort( # Another failure already aborted this execution (or it's already gone). return {"status": "already_aborted", "execution_id": execution_id} + # We won the abort: reclaim the per-batch dedup markers too (best-effort — + # the barrier is already torn down). A leftover marker would otherwise linger + # until the future dedup-orphan sweep; it can never re-run anything. + with contextlib.suppress(Exception): + clear_execution_batches(execution_id) + logger.error( f"[exec:{execution_id}] PgBarrier aborted — a header task failed; " f"barrier state cleaned up (aggregating callback will not fire)." ) return {"status": "aborted", "execution_id": execution_id} + + +def _abort_barrier_in_body(execution_id: str, *, reason: str) -> None: + """Tear the barrier down from the in-body PG path (no ``.link_error`` here). + + Best-effort: a batch failure and a DB failure are correlated, so the abort + itself can fail — that's logged (not swallowed silently) so a stuck barrier + isn't masked by a misleading "torn down" message. A failed teardown bottoms + out at the barrier's ``expires_at`` and is reclaimed by the reaper. + """ + try: + barrier_pg_abort(execution_id=execution_id) + logger.error( + f"[exec:{execution_id}] {reason} — barrier teardown attempted in-body " + f"(no .link_error on the PG path); aggregating callback won't fire." + ) + except Exception: + logger.exception( + f"[exec:{execution_id}] {reason} AND the in-body barrier teardown " + f"itself failed — barrier will hang until expiry and be reclaimed by " + f"the reaper (max_retries=0, no replay)." + ) + + +def run_batch_with_barrier( + barrier_context: BarrierContext, work_fn: Callable[[], dict[str, Any]] +) -> dict[str, Any]: + """Run a fire-and-forget PG-path batch under the barrier protocol (9e PR 2c). + + A PG-consumed header task fires no Celery ``.link``, so the barrier + coordination runs in-body here, given the ``_barrier_context`` that + :meth:`PgBarrier._dispatch_header_pg` injected (``execution_id`` / + ``batch_index`` / ``callback_descriptor``): + + 1. **Claim** ``(execution_id, batch_index)``. A redelivery (at-least-once + queue) finds the marker already set → returns a no-op result WITHOUT + re-running the work or re-decrementing — exactly-once decrement. + 2. **Run** ``work_fn()`` (the batch). + 3. **Decrement** the barrier with the batch result; the task that drives + ``remaining`` → 0 self-chains the aggregating callback onto PG. + + Steps 2 *and* 3 are wrapped: any catchable failure (work error, the decrement + guard / DB error, or the last-batch callback dispatch failing) tears the + barrier down in-body via :func:`barrier_pg_abort` and re-raises, so it fails + fast instead of hanging to expiry — mirroring the chord path's ``.link_error``. + + **Strand windows the reaper must cover (NOT catchable here — hard merge + dependency for enabling the flag in PR 3, see the module / PgBatchDedup docs):** + + - A hard crash / SIGKILL / visibility-timeout expiry *during* ``work_fn()`` + leaves the dedup marker committed (claim is step 1), so redelivery returns + ``skipped_redelivery`` and never re-runs the batch — the barrier hangs to + ``expires_at``. (Claiming before the work avoids re-processing on the common + redelivery case at the cost of this crash window.) + - A hard crash *between* the decrement committing (``remaining`` → 0) and the + callback enqueue completing: the decrement is committed (so redelivery is + blocked by the marker) but the process is gone before the callback is + enqueued and before any in-body abort can run, so the ``pg_barrier_state`` + row survives to ``expires_at`` with no callback ever fired. (A *software* + callback-dispatch failure here is NOT this window — that's catchable and is + torn down by step 3's wrap above.) + + For both, the **reaper** (sweep stranded ``pg_barrier_state``, mark the + execution ERROR / re-drive, reclaim ``pg_batch_dedup``) is the recovery net; + it must be live before PR 3 flips ``pg_queue_execution_enabled``. + + ``work_fn`` must return the JSON-serialisable batch result (the same dict the + Celery chord path returns), since the decrement appends it to the barrier's + aggregated results that the callback receives. + """ + execution_id = str(barrier_context["execution_id"]) + batch_index = int(barrier_context["batch_index"]) + + if not claim_batch(execution_id, batch_index): + logger.info( + f"[exec:{execution_id}] batch {batch_index} already claimed — " + f"redelivery, skipping (barrier already decremented for this batch)." + ) + return { + "status": "skipped_redelivery", + "execution_id": execution_id, + "batch_index": batch_index, + } + + # Wrap BOTH the work and the decrement: a decrement-side failure (the + # open-transaction guard, a json/DB error, or the last-batch callback dispatch + # raising) must also tear the barrier down, else the marker is committed, + # redelivery is blocked, and the barrier strands to expiry. + try: + result = work_fn() + _barrier_pg_decrement( + result, + execution_id=execution_id, + callback_descriptor=barrier_context["callback_descriptor"], + ) + except Exception: + _abort_barrier_in_body(execution_id, reason=f"batch {batch_index} failed") + raise + return result diff --git a/workers/queue_backend/redis_barrier.py b/workers/queue_backend/redis_barrier.py index 346e44f8b7..2c1e2fb3ee 100644 --- a/workers/queue_backend/redis_barrier.py +++ b/workers/queue_backend/redis_barrier.py @@ -69,6 +69,7 @@ from celery.canvas import Signature from unstract.core.cache.redis_client import create_redis_client +from unstract.core.data_models import DEFAULT_WORKFLOW_TRANSPORT from .barrier import ( _DEFAULT_BARRIER_TTL_SECONDS, @@ -313,10 +314,13 @@ def enqueue( callback_queue: str, app_instance: Any, fairness: FairnessKey | None = None, + transport: str = DEFAULT_WORKFLOW_TRANSPORT, ) -> BarrierHandle | None: """See :class:`queue_backend.barrier.Barrier.enqueue`. - Mirrors ``CeleryChordBarrier.enqueue`` semantics: + ``transport`` is accepted for Protocol parity and ignored — the Redis + barrier is the Celery-transport fan-in substrate (the PG transport uses + ``PgBarrier``'s fire-and-forget mode). Mirrors ``CeleryChordBarrier``: - Empty ``header_tasks`` → ``None`` (caller handles the zero-files contract); no Redis keys touched. @@ -337,7 +341,7 @@ def enqueue( """ # Explicit ``del`` documents the Protocol-parity intent and # satisfies static linters' unused-parameter check. - del app_instance + del app_instance, transport if not header_tasks: execution_id = callback_kwargs.get("execution_id") pipeline_id = callback_kwargs.get("pipeline_id") diff --git a/workers/shared/workflow/execution/orchestration_utils.py b/workers/shared/workflow/execution/orchestration_utils.py index 5b7167774c..560af7b91d 100644 --- a/workers/shared/workflow/execution/orchestration_utils.py +++ b/workers/shared/workflow/execution/orchestration_utils.py @@ -10,6 +10,9 @@ from typing import TYPE_CHECKING, Any from queue_backend import BarrierHandle, FairnessKey, get_barrier +from queue_backend.pg_barrier import PgBarrier + +from unstract.core.data_models import DEFAULT_WORKFLOW_TRANSPORT, is_pg_transport from ...enums import FileDestinationType, PipelineType from ...enums.worker_enums import QueueName @@ -17,18 +20,31 @@ if TYPE_CHECKING: from celery.canvas import Signature + from queue_backend import Barrier logger = WorkerLogger.get_logger(__name__) # Single ``Barrier`` instance reused across all -# ``WorkflowOrchestrationUtils.create_chord_execution`` calls. The -# substrate is selected at module-import (worker startup) time by the -# ``WORKER_BARRIER_BACKEND`` env var (default ``chord``). Flag flips -# require a pod restart — same posture as every other ``WORKER_*`` -# env in the codebase. +# ``WorkflowOrchestrationUtils.create_chord_execution`` calls on the **celery** +# transport. The substrate is selected at module-import (worker startup) time by +# the ``WORKER_BARRIER_BACKEND`` env var (default ``chord``). Flag flips require a +# pod restart — same posture as every other ``WORKER_*`` env in the codebase. _BARRIER = get_barrier() +def _barrier_for_transport(transport: str) -> Barrier: + """Pick the fan-in substrate for a per-execution transport (9e). + + The ``pg_queue`` transport always coordinates on Postgres — it uses a fresh + :class:`PgBarrier` in its fire-and-forget mode regardless of + ``WORKER_BARRIER_BACKEND`` (the env-selected singleton is the *celery*-transport + substrate, which may be ``chord``). Every other transport uses that singleton. + """ + if is_pg_transport(transport): + return PgBarrier() + return _BARRIER + + class WorkflowOrchestrationUtils: """Centralized workflow orchestration patterns and utilities.""" @@ -41,6 +57,7 @@ def create_chord_execution( app_instance: Any, *, fairness: FairnessKey | None = None, + transport: str = DEFAULT_WORKFLOW_TRANSPORT, ) -> BarrierHandle | None: """Standardized fan-out + callback pattern (Phase 6 ``Barrier``). @@ -71,13 +88,14 @@ def create_chord_execution( parent that direct pipeline status updates should be handled instead. """ - return _BARRIER.enqueue( + return _barrier_for_transport(transport).enqueue( batch_tasks, callback_task_name=callback_task_name, callback_kwargs=callback_kwargs, callback_queue=callback_queue, app_instance=app_instance, fairness=fairness, + transport=transport, ) @staticmethod diff --git a/workers/tests/test_barrier.py b/workers/tests/test_barrier.py index e61bc4a182..c35b088bd8 100644 --- a/workers/tests/test_barrier.py +++ b/workers/tests/test_barrier.py @@ -392,6 +392,8 @@ def test_create_chord_execution_delegates_to_module_barrier(self): fairness=fairness, ) + # Default transport (celery) → the env-selected singleton, with the + # per-execution transport threaded through to the substrate (9e). mock_barrier.enqueue.assert_called_once_with( batch, callback_task_name="process_batch_callback_api", @@ -399,6 +401,40 @@ def test_create_chord_execution_delegates_to_module_barrier(self): callback_queue="api_file_processing_callback", app_instance=app_mock, fairness=fairness, + transport="celery", + ) + + def test_pg_queue_transport_bypasses_singleton_for_pgbarrier(self): + # 9e: a pg_queue execution must coordinate on Postgres regardless of + # WORKER_BARRIER_BACKEND, so it uses a fresh PgBarrier — NOT the (possibly + # chord) singleton. Pin that the singleton's enqueue is never called. + from shared.workflow.execution import orchestration_utils + from shared.workflow.execution.orchestration_utils import ( + WorkflowOrchestrationUtils, + ) + + app_mock = MagicMock(name="celery_app") + batch = [MagicMock(name="h1")] + + with ( + patch.object(orchestration_utils, "_BARRIER") as mock_singleton, + patch.object(orchestration_utils, "PgBarrier") as mock_pgbarrier_cls, + ): + WorkflowOrchestrationUtils.create_chord_execution( + batch_tasks=batch, + callback_task_name="process_batch_callback", + callback_kwargs={"execution_id": "exec-pg"}, + callback_queue="general", + app_instance=app_mock, + transport="pg_queue", + ) + + mock_singleton.enqueue.assert_not_called() # NOT the env singleton + mock_pgbarrier_cls.assert_called_once_with() # a fresh PgBarrier + mock_pgbarrier_cls.return_value.enqueue.assert_called_once() + assert ( + mock_pgbarrier_cls.return_value.enqueue.call_args.kwargs["transport"] + == "pg_queue" ) diff --git a/workers/tests/test_chord_callback_boundary.py b/workers/tests/test_chord_callback_boundary.py index 524708a3e5..088609420a 100644 --- a/workers/tests/test_chord_callback_boundary.py +++ b/workers/tests/test_chord_callback_boundary.py @@ -767,5 +767,45 @@ def test_aggregator_consumes_multi_batch(self): assert aggregated["batches_processed"] == 2 +class TestProcessFileBatchTransportRouting: + """9e PR 2c: ``_process_file_batch_core`` routes by ``barrier_context`` — + Celery chord path runs the stages directly (the chord ``.link`` decrements); + the PG path wraps them in ``run_batch_with_barrier`` (in-body claim + decrement). + """ + + def test_celery_path_runs_stages_directly(self, monkeypatch): + from file_processing import tasks as tasks_mod + + monkeypatch.setattr(tasks_mod, "_run_batch_stages", lambda *a: {"r": "celery"}) + barrier_calls = [] + monkeypatch.setattr( + tasks_mod, + "run_batch_with_barrier", + lambda *a, **k: barrier_calls.append(1), + ) + out = tasks_mod._process_file_batch_core(MagicMock(), {"fb": 1}, None) + assert out == {"r": "celery"} + assert barrier_calls == [] # barrier path NOT taken on the celery transport + + def test_pg_path_routes_through_barrier(self, monkeypatch): + from file_processing import tasks as tasks_mod + + monkeypatch.setattr(tasks_mod, "_run_batch_stages", lambda *a: {"r": "work"}) + captured = {} + + def fake_run_batch_with_barrier(ctx, work_fn): + captured["ctx"] = ctx + return {"via": "barrier", "work": work_fn()} + + monkeypatch.setattr( + tasks_mod, "run_batch_with_barrier", fake_run_batch_with_barrier + ) + ctx = {"execution_id": "e", "batch_index": 0, "callback_descriptor": {}} + out = tasks_mod._process_file_batch_core(MagicMock(), {"fb": 1}, ctx) + assert captured["ctx"] is ctx + assert out["via"] == "barrier" + assert out["work"] == {"r": "work"} # work_fn runs the real stages + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index c2a9488560..238bbeeabc 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -24,10 +24,24 @@ from queue_backend.pg_barrier import ( PgBarrier, _barrier_pg_decrement, + _fire_barrier_callback, barrier_pg_abort, barrier_pg_decr_and_check, + claim_batch, + run_batch_with_barrier, ) from queue_backend.pg_queue.connection import create_pg_connection +from queue_backend.routing import QueueBackend + + +def _pg_header(task_name="process_file_batch", args=None, queue="file_processing"): + """A Celery-Signature-shaped stub for the PG-fan-out path (.task/.args/.kwargs/.options).""" + sig = MagicMock(name="pg_header_signature") + sig.task = task_name + sig.args = args if args is not None else [{"file": "f1"}] + sig.kwargs = {} + sig.options = {"queue": queue} + return sig _CALLBACK = { "task_name": "process_batch_callback_api", @@ -478,5 +492,277 @@ def test_expires_at_must_exceed_created_at(self, barrier_db): ) +class TestPgFireAndForgetMode: + """9e PR 2c — PgBarrier's ``transport="pg_queue"`` fire-and-forget path: + headers dispatched onto PG (no ``.link``), in-body claim + decrement, callback + self-chained onto PG, and dedup-marker cleanup at enqueue/finalise/abort. + """ + + def test_enqueue_pg_mode_dispatches_headers_via_pg_with_context(self, barrier_db): + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + # A header with a pre-existing kwarg, real args, and a queue — to prove + # the Signature→dispatch unpacking preserves all three (a dropped args or + # queue would silently process an empty/misrouted batch). + h0 = _pg_header(args=[{"file": "f0"}], queue="api_file_processing") + h0.kwargs = {"pre_existing": "keep"} + h1 = _pg_header(args=[{"file": "f1"}], queue="api_file_processing") + with patch("queue_backend.dispatch.dispatch") as mock_dispatch: + PgBarrier().enqueue( + [h0, h1], + callback_task_name="process_batch_callback", + callback_kwargs={"execution_id": "exec-pg"}, + callback_queue="general", + app_instance=None, + transport="pg_queue", + ) + assert mock_dispatch.call_count == 2 # one per header, no .link + for i, call in enumerate(mock_dispatch.call_args_list): + assert call.kwargs["backend"] is QueueBackend.PG + assert call.kwargs["args"] == [{"file": f"f{i}"}] # args preserved + assert call.kwargs["queue"] == "api_file_processing" # queue preserved + ctx = call.kwargs["kwargs"]["_barrier_context"] + assert ctx["execution_id"] == "exec-pg" + assert ctx["batch_index"] == i + assert ctx["callback_descriptor"]["transport"] == "pg_queue" + # The pre-existing kwarg on h0 survives alongside the injected context. + assert mock_dispatch.call_args_list[0].kwargs["kwargs"]["pre_existing"] == "keep" + + def test_enqueue_pg_mode_clears_stale_dedup_on_reuse(self, barrier_db): + # greptile #2068: a re-enqueue with the same execution_id must wipe prior + # dedup markers atomically with the barrier reset, else every claim_batch + # returns False and the barrier hangs. + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + claim_batch("exec-reuse", 0) + claim_batch("exec-reuse", 1) + with patch("queue_backend.dispatch.dispatch"): + PgBarrier().enqueue( + [_pg_header()], + callback_task_name="process_batch_callback", + callback_kwargs={"execution_id": "exec-reuse"}, + callback_queue="general", + app_instance=None, + transport="pg_queue", + ) + with barrier_db.cursor() as cur: + cur.execute( + "SELECT count(*) FROM pg_batch_dedup WHERE execution_id = %s", + ("exec-reuse",), + ) + assert cur.fetchone()[0] == 0 # stale markers wiped by the UPSERT block + + def test_run_batch_with_barrier_first_delivery_runs_and_decrements(self, barrier_db): + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + _seed(barrier_db, "exec-fd", 2) + ctx = { + "execution_id": "exec-fd", + "batch_index": 0, + "callback_descriptor": _CALLBACK, + } + work = MagicMock(return_value={"ok": 1}) + out = run_batch_with_barrier(ctx, work) + work.assert_called_once() + assert out == {"ok": 1} + remaining, results = _row(barrier_db, "exec-fd") + assert remaining == 1 + assert results == [{"ok": 1}] + + def test_run_batch_with_barrier_redelivery_skips_work_and_decrement(self, barrier_db): + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + _seed(barrier_db, "exec-re", 2) + claim_batch("exec-re", 0) # batch already claimed → this is a redelivery + ctx = { + "execution_id": "exec-re", + "batch_index": 0, + "callback_descriptor": _CALLBACK, + } + work = MagicMock(return_value={"ok": 1}) + out = run_batch_with_barrier(ctx, work) + work.assert_not_called() # no reprocessing + assert out["status"] == "skipped_redelivery" + remaining, _ = _row(barrier_db, "exec-re") + assert remaining == 2 # NOT decremented again + + def test_run_batch_with_barrier_exception_aborts_barrier(self, barrier_db): + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + _seed(barrier_db, "exec-err", 2) + ctx = { + "execution_id": "exec-err", + "batch_index": 0, + "callback_descriptor": _CALLBACK, + } + + def boom(): + raise RuntimeError("batch failed") + + with pytest.raises(RuntimeError, match="batch failed"): + run_batch_with_barrier(ctx, boom) + # No .link_error on the PG path → torn down in-body (mirror chord abort). + assert _row(barrier_db, "exec-err") is None + + def test_fire_barrier_callback_pg_self_chains_via_dispatch(self): + descriptor = {**_CALLBACK, "transport": "pg_queue"} + with patch("queue_backend.dispatch.dispatch") as mock_dispatch: + mock_dispatch.return_value = MagicMock(id="pg-cb-1") + cb_id = _fire_barrier_callback(descriptor, [{"r": 1}]) + assert cb_id == "pg-cb-1" + assert mock_dispatch.call_args.kwargs["backend"] is QueueBackend.PG + assert mock_dispatch.call_args.kwargs["args"] == [[{"r": 1}]] + + def test_fire_barrier_callback_pg_carries_fairness(self): + # greptile: the PG callback must ride the producer's org/priority (parity + # with the Celery path), reconstructed from the stored fairness_headers. + descriptor = { + **_CALLBACK, + "transport": "pg_queue", + "fairness_headers": { + FAIRNESS_HEADER_NAME: { + "org_id": "org-9", + "workload_type": "api", + "pipeline_priority": 8, + } + }, + } + with patch("queue_backend.dispatch.dispatch") as mock_dispatch: + mock_dispatch.return_value = MagicMock(id="pg-cb") + _fire_barrier_callback(descriptor, [{"r": 1}]) + fairness = mock_dispatch.call_args.kwargs["fairness"] + assert fairness is not None + assert fairness.org_id == "org-9" + assert fairness.pipeline_priority == 8 + + def test_fire_barrier_callback_pg_without_fairness_passes_none(self): + # No producer key → None (dispatch writes neutral defaults), not a crash. + descriptor = {**_CALLBACK, "transport": "pg_queue"} # fairness_headers None + with patch("queue_backend.dispatch.dispatch") as mock_dispatch: + mock_dispatch.return_value = MagicMock(id="pg-cb") + _fire_barrier_callback(descriptor, [{"r": 1}]) + assert mock_dispatch.call_args.kwargs["fairness"] is None + + def test_fire_barrier_callback_legacy_uses_celery(self): + # No backend marker → the .link-mode Celery dispatch (unchanged). + with patch("celery.current_app.signature") as sig: + sig.return_value.apply_async.return_value = MagicMock(id="celery-cb") + cb_id = _fire_barrier_callback(_CALLBACK, [{"r": 1}]) + assert cb_id == "celery-cb" + sig.assert_called_once() + + def test_abort_clears_dedup_markers(self, barrier_db): + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + _seed(barrier_db, "exec-ab", 2) + claim_batch("exec-ab", 0) + barrier_pg_abort(execution_id="exec-ab") + with barrier_db.cursor() as cur: + cur.execute( + "SELECT count(*) FROM pg_batch_dedup WHERE execution_id = %s", + ("exec-ab",), + ) + assert cur.fetchone()[0] == 0 + + def test_last_batch_self_chains_callback_to_pg_and_cleans_up(self, barrier_db): + # The PR's core promise, end to end: the batch that drives remaining → 0 + # self-chains the aggregating callback onto PG (backend=PG, args=[results]) + # and clears both the barrier row and the dedup markers. + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + _seed(barrier_db, "exec-last", 1) # this batch is the last → remaining → 0 + descriptor = { + "task_name": "process_batch_callback_api", + "kwargs": {"execution_id": "exec-last"}, + "queue": "api_file_processing_callback", + "fairness_headers": None, + "transport": "pg_queue", + } + ctx = { + "execution_id": "exec-last", + "batch_index": 0, + "callback_descriptor": descriptor, + } + with patch("queue_backend.dispatch.dispatch") as mock_dispatch: + mock_dispatch.return_value = MagicMock(id="pg-cb") + out = run_batch_with_barrier(ctx, lambda: {"successful_files": 1}) + assert out == {"successful_files": 1} + # callback self-chained onto PG with the aggregated results + assert mock_dispatch.call_args.kwargs["backend"] is QueueBackend.PG + assert mock_dispatch.call_args.args[0] == "process_batch_callback_api" + assert mock_dispatch.call_args.kwargs["args"] == [[{"successful_files": 1}]] + # barrier row + dedup markers cleaned up at finalise + assert _row(barrier_db, "exec-last") is None + with barrier_db.cursor() as cur: + cur.execute( + "SELECT count(*) FROM pg_batch_dedup WHERE execution_id = %s", + ("exec-last",), + ) + assert cur.fetchone()[0] == 0 + + def test_decrement_failure_aborts_barrier(self, barrier_db): + # #69: a decrement-side failure (after the work succeeded) must tear the + # barrier down in-body — else the dedup marker is committed, redelivery is + # blocked, and the barrier strands to expiry. + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + _seed(barrier_db, "exec-decfail", 2) + ctx = { + "execution_id": "exec-decfail", + "batch_index": 0, + "callback_descriptor": _CALLBACK, + } + with patch.object( + pg_barrier, "_barrier_pg_decrement", side_effect=RuntimeError("decr boom") + ): + with pytest.raises(RuntimeError, match="decr boom"): + run_batch_with_barrier(ctx, lambda: {"ok": 1}) + assert _row(barrier_db, "exec-decfail") is None # barrier torn down + + def test_pg_mid_loop_dispatch_failure_deletes_row_and_clears_dedup(self, barrier_db): + # The PG-branch counterpart of test_mid_loop_dispatch_failure_deletes_row: + # a header PG-dispatch failure mid fan-out deletes the barrier row AND + # reclaims dedup markers (greptile #2069) — an already-claimed earlier + # header's marker would otherwise orphan, since the in-flight abort is a + # no-op once the barrier row is gone. + # + # The marker must be claimed AFTER enqueue's UPSERT block (which wipes + # stale markers for this execution_id) — else the assertion would pass on + # the UPSERT, not the mid-loop clear under test. So the first dispatch + # call claims it (a fast consumer on the PG path), the second fails. + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + + def dispatch_side_effect(*args, **kwargs): + if not dispatch_side_effect.claimed: + dispatch_side_effect.claimed = True + claim_batch("exec-midfail", 0) # marker created post-UPSERT + return MagicMock(id="1") + raise RuntimeError("broker down") + + dispatch_side_effect.claimed = False + with patch( + "queue_backend.dispatch.dispatch", side_effect=dispatch_side_effect + ): + with pytest.raises(RuntimeError, match="broker down"): + PgBarrier().enqueue( + [_pg_header(), _pg_header()], + callback_task_name="process_batch_callback", + callback_kwargs={"execution_id": "exec-midfail"}, + callback_queue="general", + app_instance=None, + transport="pg_queue", + ) + assert _row(barrier_db, "exec-midfail") is None # row deleted on failure + with barrier_db.cursor() as cur: + cur.execute( + "SELECT count(*) FROM pg_batch_dedup WHERE execution_id = %s", + ("exec-midfail",), + ) + # The marker existed post-UPSERT and was removed by the mid-loop + # clear_execution_batches under test (not by the UPSERT reset). + assert cur.fetchone()[0] == 0 + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From c872b3417a403328c5e7fda227e5c289351da794 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:22:26 +0530 Subject: [PATCH 19/65] =?UTF-8?q?UN-3564=20[FEAT]=20PG=20Queue=209e=20?= =?UTF-8?q?=E2=80=94=20reaper-recovery:=20mark=20stranded=20executions=20E?= =?UTF-8?q?RROR=20(gates=20PR=203)=20(#2070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3564 [FEAT] PG Queue 9e — reaper-recovery: mark stranded executions ERROR The reaper only DELETEd expired pg_barrier_state rows — a stranded execution vanished silently (stuck in EXECUTING). This makes it RECOVER them — the hard dependency from PR 2c review for enabling the PG transport (un-catchable strand windows otherwise bottom out at the ~6h barrier expiry). Per expired barrier the leader (recover_expired_barriers), best-effort + per-exec: - Marks the execution ERROR via the internal API (the path the normal callback uses — business state never goes direct-DB; the API is functional: execution_time, error truncation, attempts, events/notifications, multi-tenant boundary). Message distinguishes remaining>0 (work incomplete) vs remaining==0 (callback never fired). Reads status first and SKIPS if already terminal (a remaining==0 row can be a COMPLETED exec whose row-delete failed; update_execution has no terminal guard) or if the row has no org. - Reclaims pg_batch_dedup + pg_barrier_state directly in PG (same boundary as the rest of queue_backend). Recover-THEN-delete: a failed mark leaves the row for the next sweep to retry (single-leader → no double-claim). - backend: organization_id column on PgBarrierState + migration 0007 (reaper needs it for the org-scoped status API). - workers: PgBarrier.enqueue stamps organization_id into the UPSERT; PgReaper holds a lazily-built InternalAPIClient; sweep_expired_barriers → recover_expired_barriers. Tests: reaper suite reworked — real-PG recovery w/ fake API client (mark-ERROR remaining>0/==0, terminal-skip no-overwrite, org-missing skip, API-failure leaves row, dedup reclaim, tick-via-real-conn). 109 reaper/barrier/dedup tests green. Dev-tested vs real InternalAPIClient+backend: PENDING stranded → ERROR + cleaned; COMPLETED → NOT overwritten + cleaned (terminal-guard verified end-to-end). Deferred: callback re-fire for remaining==0 (needs callback_descriptor on the row). Co-Authored-By: Claude Opus 4.8 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * UN-3564 fix SonarCloud S6741: avoid IndexError-prone update_calls[0] in reaper tests Replaced `api.update_calls[0]` (which Sonar can't prove non-empty → IndexError risk) with single-element unpacking `(call,) = api.update_calls` — removes the index and additionally asserts exactly one status mark was made. Behaviour identical; 52 reaper tests green. Co-Authored-By: Claude Opus 4.8 * UN-3564 address review (muhammad-ali-e, 11): fix read-failure→false-ERROR (Critical) + hardening - [Critical] _execution_status now RAISES when the status read fails (get_workflow_execution returns success=False, never raises) — a transient blip no longer reads as "non-terminal" and flips a COMPLETED execution to ERROR; the caller's except retains the row for retry. + test. - [Medium] use ExecutionStatus.is_completed (single source of truth) instead of a local _TERMINAL_STATUSES frozenset that could drift; dropped the fake in (_TERMINAL_STATUSES) parens. - [Medium] remaining is NOT-NULL int → typed int, three-way branch (>0 work-incomplete / ==0 callback-never-fired / <0 already-torn-down) with accurate messages. - [Medium] re-guard the barrier DELETE on `expires_at < now()` + only clear dedup when the barrier row was actually reclaimed — a same-id re-enqueue (UPSERT resets expires_at) is no longer torn down mid-recovery. - [Medium] org-missing now LEAVES the row (doesn't erase the only recovery handle) + logs ERROR; PgBarrier.enqueue logs ERROR at write time if a barrier is enqueued without an org (should never happen). - [Low] recover_expired_barriers emits an aggregate summary; escalates to logger.error when a non-empty sweep recovers nothing (systemic: API down). - [Low] refreshed the stale "(future) periodic sweep" comment in pg_barrier. Tests: _FakeApiClient models the real success/failure contract + records file_execution; +read-failure-retains-row, +file_execution=False, +multi-row isolation (one fails, others recover), +org-stamping x3 (stamp/default/UPSERT refresh). 115 reaper/barrier/dedup tests green; ruff + ruff-format clean. Co-Authored-By: Claude Opus 4.8 * UN-3564 fix SonarCloud S6741 (new instance): index-free get_calls access A new occurrence introduced by the prior review-round test (test_status_read_passes_file_execution_false used api.get_calls[0][2]). Replaced with single-element unpacking — [(_exec_id, _org, file_execution)] = api.get_calls — index-free and additionally pins exactly one status read. 55 reaper tests green. Co-Authored-By: Claude Opus 4.8 * UN-3564 address greptile #2070: re-arm race, status=None edge, aggregate skip-vs-fail - [main] re-arm race: the ERROR mark now fires only after a re-check that the row is STILL expired (_still_expired) immediately before marking — so a same-id re-enqueue (expires_at reset to future) between the sweep SELECT and the mark can't get its freshly-running execution flagged ERROR. The DELETE stays guarded on expires_at < now() as the second line of defence. + test (re-arm via the status-read side-effect) asserting no mark + row left intact. - status=None on a success=True read no longer falls through to mark ERROR — treated as indeterminate: skip + leave the row for the next sweep. + test. - aggregate logging distinguishes genuine failures (exceptions) from benign skips (terminal / re-armed / no-status / no-org): the systemic "recovered NONE / API down" ERROR escalates only on real failures, not on all-skipped sweeps. + caplog test that an all-org-missing sweep doesn't escalate. 58 reaper + 51 barrier tests green; ruff + ruff-format clean. Co-Authored-By: Claude Opus 4.8 * UN-3564 fix SonarCloud S125: drop code-like tuple from a test comment The comment ended with a parenthesised tuple "(exec_id, org, file_execution)" — a complete Python expression S125 reads as commented-out code. Reworded to prose (and the stale `sweep_expired_barriers`→recover rename + a couple of inline `foo()` prose mentions neutralised while here). Comment-only; tests unaffected. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../0007_pgbarrierstate_organization_id.py | 17 + backend/pg_queue/models.py | 5 + workers/queue_backend/pg_barrier.py | 21 +- workers/queue_backend/pg_queue/__init__.py | 4 +- workers/queue_backend/pg_queue/reaper.py | 312 ++++++++++++++++-- workers/tests/test_pg_barrier.py | 66 +++- workers/tests/test_pg_reaper.py | 288 +++++++++++++--- 7 files changed, 618 insertions(+), 95 deletions(-) create mode 100644 backend/pg_queue/migrations/0007_pgbarrierstate_organization_id.py diff --git a/backend/pg_queue/migrations/0007_pgbarrierstate_organization_id.py b/backend/pg_queue/migrations/0007_pgbarrierstate_organization_id.py new file mode 100644 index 0000000000..b3455e5a26 --- /dev/null +++ b/backend/pg_queue/migrations/0007_pgbarrierstate_organization_id.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.1 on 2026-06-17 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0006_pgbatchdedup_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="pgbarrierstate", + name="organization_id", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index 079508619c..e1f9f3728c 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -194,6 +194,11 @@ class PgBarrierState(models.Model): """ execution_id = models.TextField(primary_key=True) + # Owning org, stamped at enqueue. The reaper needs it to call the org-scoped + # internal status API when recovering a stranded (expired) barrier — it has + # only the execution_id otherwise. "" = unknown (the reaper then skips the API + # mark and just cleans up). Same no-NULL-text convention as PgQueueMessage.org_id. + organization_id = models.TextField(blank=True, default="") # Header tasks still pending. The last task to decrement it to 0 fires the # callback. A value < 0 (decrement after expiry/cleanup) means the barrier # was already torn down — the task cleans up without firing. diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 956ce09a05..19e80f29a1 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -291,6 +291,19 @@ def enqueue( # self-chain the callback onto PG rather than Celery. callback_descriptor["transport"] = WorkflowTransport.PG_QUEUE.value ttl_seconds = barrier_ttl_seconds() + # Stamp the owning org so the reaper can call the org-scoped status + # API when recovering this barrier if it strands (it has only the + # execution_id off the row otherwise). + organization_id = str(callback_kwargs.get("organization_id") or "") + if not organization_id: + # Should never happen — every fan-out passes organization_id in + # callback_kwargs. Surface loudly: a barrier with no org can't be + # recovered via the org-scoped API if it strands. + logger.error( + f"[exec:{execution_id}] PgBarrier enqueued with NO " + f"organization_id in callback_kwargs — a stranded barrier " + f"could not be reaper-recovered. This is a bug in the caller." + ) with _cursor() as cur: # UPSERT clears any leftover state from a prior run with this id. @@ -300,13 +313,15 @@ def enqueue( # periodic sweep keyed on pg_barrier_expires_idx. cur.execute( "INSERT INTO pg_barrier_state " - "(execution_id, remaining, results, created_at, expires_at) " - "VALUES (%s, %s, '[]'::jsonb, now(), " + "(execution_id, organization_id, remaining, results, " + " created_at, expires_at) " + "VALUES (%s, %s, %s, '[]'::jsonb, now(), " " now() + make_interval(secs => %s)) " "ON CONFLICT (execution_id) DO UPDATE SET " + " organization_id = EXCLUDED.organization_id, " " remaining = EXCLUDED.remaining, results = '[]'::jsonb, " " created_at = now(), expires_at = EXCLUDED.expires_at", - (execution_id, len(header_tasks), ttl_seconds), + (execution_id, organization_id, len(header_tasks), ttl_seconds), ) # Reset per-batch dedup markers from a prior run reusing this # execution_id, ATOMICALLY with the barrier reset. Without this a diff --git a/workers/queue_backend/pg_queue/__init__.py b/workers/queue_backend/pg_queue/__init__.py index ec228f4617..28139cefc2 100644 --- a/workers/queue_backend/pg_queue/__init__.py +++ b/workers/queue_backend/pg_queue/__init__.py @@ -39,7 +39,7 @@ ReaperLivenessServer, TickOutcome, reaper_interval_from_env, - sweep_expired_barriers, + recover_expired_barriers, ) from .task_payload import TaskPayload, to_payload @@ -57,6 +57,6 @@ "default_worker_id", "lease_seconds_from_env", "reaper_interval_from_env", - "sweep_expired_barriers", + "recover_expired_barriers", "to_payload", ] diff --git a/workers/queue_backend/pg_queue/reaper.py b/workers/queue_backend/pg_queue/reaper.py index ae05d8812b..6bf5d7ec2c 100644 --- a/workers/queue_backend/pg_queue/reaper.py +++ b/workers/queue_backend/pg_queue/reaper.py @@ -2,22 +2,34 @@ A singleton, guarded by :class:`LeaderLease` over ``pg_orchestrator_lock``: only the elected leader runs recovery work each cycle (several reapers would contend -and double-act). This slice ships the process *harness* (lease-maintenance loop -+ graceful shutdown) plus ONE recovery job — the **barrier-orphan sweep**. +and double-act). It ships the process *harness* (lease-maintenance loop + +graceful shutdown) plus the **barrier-orphan recovery** job. -**Barrier-orphan sweep.** Reclaims ``pg_barrier_state`` rows past their +**Barrier-orphan recovery.** Handles ``pg_barrier_state`` rows past their ``expires_at`` — a barrier whose header tasks never all completed (the documented -:class:`PgBarrier` backstop). It ``DELETE``s the orphaned row; by PgBarrier's -existing semantics a late in-flight decrement then finds no row and abandons (no -spurious callback). The owning execution is logged loudly. Marking that -execution *terminal* (ERROR) is recovery that needs the backend and the -pipeline's PG shape — that's 9e, not here; this slice is the storage/orphan -backstop only. - -**Deferred to 9e.** Pipeline recovery (counter reconstruction from -``WorkflowFileExecution``, per-stage re-enqueue of stuck file executions) is -defined against the coupled pipeline running on PG, which doesn't exist yet — so -it lands with 9e, against a real PG pipeline it can be tested on. +:class:`PgBarrier` backstop). For each, the leader (:func:`recover_expired_barriers`): + +1. **Marks the execution ERROR** via the internal API — the same path the normal + callback uses for terminal status (business state never goes direct-DB) — with + a message distinguishing ``remaining>0`` (work incomplete) from ``remaining==0`` + (all batches done, callback never fired). It reads status first and **skips the + mark if the execution is already terminal** (a ``remaining==0`` row can belong + to a COMPLETED execution whose best-effort row-delete merely failed, and the + backend status update has no terminal guard) or if the row carries no org. +2. **Reclaims the queue-infra rows** (``pg_batch_dedup`` + ``pg_barrier_state``) + directly in PG — same boundary as the rest of ``queue_backend``. + +Recovery is best-effort and per-execution: a failure (e.g. the API is +unreachable) leaves that barrier row for the next sweep to retry, and never +blocks the others. **This is the recovery net PG-queue execution rollout depends +on** — without it the un-catchable strand windows (a worker SIGKILL mid-batch, or +a crash after the final decrement but before the callback enqueues) would bottom +out silently at the ~6h barrier expiry. + +**Deferred (follow-up).** *Callback re-fire* for the ``remaining==0`` strand +(heal → COMPLETED instead of ERROR) needs the ``callback_descriptor`` stored on +the barrier row; until then those strands are marked ERROR. Per-stage re-enqueue +of stuck file executions is a larger pipeline-recovery effort beyond this net. **Lease maintenance.** Each cycle the leader renews; if ``renew()`` returns ``False`` (or raises) it lost / can't confirm the lease and steps down to @@ -41,12 +53,15 @@ import time from typing import TYPE_CHECKING, NamedTuple, Protocol +from unstract.core.data_models import ExecutionStatus + from .connection import create_pg_connection from .leader_election import LeaderLease, default_worker_id from .liveness import LivenessServer as _BaseLivenessServer if TYPE_CHECKING: from psycopg2.extensions import connection as PgConnection + from shared.api import InternalAPIClient logger = logging.getLogger(__name__) @@ -111,40 +126,249 @@ def reaper_interval_from_env() -> float: return value -def sweep_expired_barriers(conn: PgConnection) -> list[str]: - """Reclaim ``pg_barrier_state`` rows past ``expires_at``. Returns their ids. +def _execution_status( + api_client: InternalAPIClient, execution_id: str, organization_id: str +) -> str | None: + """Current execution status via the org-scoped internal API. + + **Raises** when the read fails. The client catches all errors and returns a + ``success=False`` response (it does NOT raise), so a transient blip would + yield ``status=None`` — which is not terminal, and the caller would then mark + a possibly-COMPLETED execution ERROR. Treating "couldn't read" as a hard stop + (the caller's ``except`` retains the row for retry) is what keeps the + terminal-skip guard honest. + """ + response = api_client.get_workflow_execution( + execution_id, organization_id=organization_id, file_execution=False + ) + if not getattr(response, "success", False): + raise RuntimeError( + f"status read failed for execution {execution_id} " + f"(refusing to mark ERROR on an unconfirmed status)" + ) + return getattr(response, "status", None) + - A single atomic ``DELETE … RETURNING``: concurrent sweepers would each - reclaim a disjoint subset (``RETURNING`` reports only the rows *this* - statement deleted), so it stays correct even if leadership gating ever fails — - in practice only the leader calls it. Each reclaimed barrier is logged at - WARNING: an orphaned barrier means an execution's header tasks never all - completed, worth surfacing even though deleting the row is the right backstop. +def _still_expired(conn: PgConnection, execution_id: str) -> bool: + """True iff the barrier row is still present AND still past expiry. - ``conn`` runs in manual-commit mode, so on any error we roll back before - re-raising — otherwise the connection is left in an aborted-transaction state - and every later statement on it fails with ``InFailedSqlTransaction``. + Re-checked immediately before the ERROR mark so a same-id re-enqueue (UPSERT + resets ``expires_at`` to the future) between the sweep's SELECT and the mark + doesn't get its live run flagged ERROR. + """ + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM pg_barrier_state " + "WHERE execution_id = %s AND expires_at < now()", + (execution_id,), + ) + found = cur.fetchone() is not None + conn.commit() + return found + + +def _mark_stranded_error( + api_client: InternalAPIClient, + execution_id: str, + organization_id: str, + remaining: int, +) -> None: + """Mark a confirmed-non-terminal stranded execution ERROR (message by remaining).""" + if remaining > 0: + reason = ( + f"{remaining} file batch(es) never completed before the barrier " + f"expired (worker crash / lost task)" + ) + elif remaining == 0: + reason = ( + "all file batches completed but the final aggregating callback never " + "fired before the barrier expired" + ) + else: # remaining < 0: a decrement landed after the row was torn down + reason = ( + "the barrier was already torn down (remaining < 0) yet the execution " + "was left non-terminal — inconsistent state" + ) + api_client.update_workflow_execution_status( + execution_id=execution_id, + status=ExecutionStatus.ERROR.value, + error_message=f"[reaper-recovery] Execution stranded: {reason}.", + organization_id=organization_id, + ) + logger.error( + "Reaper: marked stranded execution %s ERROR (remaining=%s).", + execution_id, + remaining, + ) + + +def _recover_one_barrier( + conn: PgConnection, + api_client: InternalAPIClient, + execution_id: str, + organization_id: str, + remaining: int, +) -> bool: + """Recover one stranded execution; return True iff its barrier row was deleted. + + Marks the execution ERROR via the **internal API** (the path the normal + callback uses for terminal status — business state never goes direct-DB), + UNLESS the status read shows it's already terminal (``ExecutionStatus + .is_completed`` — the single source of truth, so a future terminal status + can't drift from a local copy). A ``remaining==0`` expired row can belong to + a COMPLETED execution whose best-effort row-delete merely failed, and the + backend status update has no terminal guard, so the read-first skip prevents + overwriting a finished execution. Queue-infra cleanup (``pg_batch_dedup`` / + ``pg_barrier_state``) stays direct-PG. + + The barrier ``DELETE`` is re-guarded on ``expires_at < now()``: between the + sweep's SELECT and here the same ``execution_id`` could be re-enqueued + (UPSERT resets ``expires_at`` to the future), and we must not tear down a + freshly re-armed barrier. If the row was re-armed (``rowcount == 0``) we leave + it and its dedup markers (the new run owns them); the dedup delete only runs + when the barrier row was actually reclaimed. + + Returns False (no mark, no delete) when the row can't be safely recovered: + org unknown (can't call the org-scoped API → the row is LEFT, not erased, so + the only recovery handle survives for ops; should never happen), a successful + read with no status (anomalous), or the row was **re-armed** before the mark. + The mark is gated on a re-check that the row is *still* expired immediately + before it fires — so a same-id re-enqueue can't get its live run marked ERROR + (the worst outcome) — and the DELETE is additionally guarded on + ``expires_at < now()`` so a re-armed barrier is never torn down. + """ + if not organization_id: + logger.error( + "Reaper: stranded barrier for execution %s has NO organization_id — " + "cannot mark it ERROR via the org-scoped API; leaving the row (not " + "erasing the only recovery handle). A barrier was enqueued without an " + "org — investigate.", + execution_id, + ) + return False + + status = _execution_status(api_client, execution_id, organization_id) + if status is None: + # A successful read with no status is anomalous — don't mark on it; leave + # the row for the next sweep rather than risk a wrong ERROR. + logger.warning( + "Reaper: status read for execution %s returned no status — leaving the " + "row for the next sweep (not marking ERROR on an indeterminate status).", + execution_id, + ) + return False + if ExecutionStatus.is_completed(status): + logger.warning( + "Reaper: barrier for execution %s expired but the execution is already " + "%s — cleaning up the orphaned row only (no status overwrite).", + execution_id, + status, + ) + elif not _still_expired(conn, execution_id): + # Re-armed (same execution_id re-enqueued) between the read and here — + # do NOT mark a freshly-running execution ERROR; leave it for the new run. + logger.warning( + "Reaper: execution %s was re-armed during recovery — skipping the " + "ERROR mark (its new run owns the barrier).", + execution_id, + ) + return False + else: + _mark_stranded_error(api_client, execution_id, organization_id, remaining) + + # Queue-infra cleanup (direct PG), re-guarded against a concurrent re-arm. + with conn.cursor() as cur: + cur.execute( + "DELETE FROM pg_barrier_state WHERE execution_id = %s " + "AND expires_at < now()", + (execution_id,), + ) + deleted = cur.rowcount > 0 + if deleted: + cur.execute( + "DELETE FROM pg_batch_dedup WHERE execution_id = %s", (execution_id,) + ) + else: + logger.warning( + "Reaper: barrier for execution %s was re-armed during recovery " + "(no longer expired) — leaving its rows for the new run.", + execution_id, + ) + conn.commit() + return deleted + + +def recover_expired_barriers( + conn: PgConnection, api_client: InternalAPIClient +) -> list[str]: + """Recover executions stranded by an expired barrier. Returns recovered ids. + + SELECT the expired rows (the reaper is leader-elected → a single active + sweeper, so a read-then-act is safe from double-claim), then recover each one + best-effort: mark the execution ERROR if it isn't already terminal, then + delete its ``pg_batch_dedup`` + ``pg_barrier_state`` rows. One execution + failing (e.g. the API is unreachable, or a status read that can't be + confirmed) is logged and skipped — its row is left for the next sweep to + retry — so it never blocks the others. A non-empty sweep that recovers + *nothing* is escalated as a systemic failure (API down / bad migration). + + ``conn`` runs in manual-commit mode; on any error we roll back before + continuing/re-raising so the connection isn't left in an aborted-txn state. """ try: with conn.cursor() as cur: cur.execute( - "DELETE FROM pg_barrier_state WHERE expires_at < now() " - "RETURNING execution_id" + "SELECT execution_id, organization_id, remaining " + "FROM pg_barrier_state WHERE expires_at < now()" ) - reclaimed = [row[0] for row in cur.fetchall()] + rows = cur.fetchall() conn.commit() except Exception: with contextlib.suppress(Exception): conn.rollback() raise - for execution_id in reclaimed: - logger.warning( - "Reaper: reclaimed orphaned barrier for execution %s — header tasks " - "never all completed before expiry; barrier deleted (no callback " - "fired). Execution terminal-status recovery is 9e's job.", - execution_id, + + recovered: list[str] = [] + failed = 0 # genuine failures (exceptions) — NOT benign skips + for execution_id, organization_id, remaining in rows: + try: + if _recover_one_barrier( + conn, api_client, execution_id, organization_id, remaining + ): + recovered.append(execution_id) + # else: a benign skip (terminal / re-armed / no-status / no-org) — + # logged per-row inside; not a failure, not retried-as-error. + except Exception: + # Keep the connection usable for the next row, and leave THIS barrier + # row in place so the next sweep retries its recovery. + failed += 1 + with contextlib.suppress(Exception): + conn.rollback() + logger.exception( + "Reaper: failed to recover stranded barrier for execution %s — " + "leaving the row for the next sweep to retry.", + execution_id, + ) + + if rows: + skipped = len(rows) - len(recovered) - failed + summary = ( + f"recovered={len(recovered)}, failed={failed}, skipped={skipped} " + f"of {len(rows)} expired barrier(s)" ) - return reclaimed + if failed and not recovered: + # Genuine failures and nothing got through → systemic (API down / bad + # migration). Benign skips alone (terminal/re-armed/no-org) don't escalate. + logger.error( + "Reaper: %s — likely systemic (internal API down / bad migration).", + summary, + ) + elif failed: + logger.warning("Reaper: %s — failures left for the next sweep.", summary) + elif recovered: + logger.info("Reaper: %s.", summary) + # all-skipped (no recovered, no failed) is fully covered by per-row logs. + return recovered class PgReaper: @@ -156,6 +380,7 @@ def __init__( *, interval_seconds: float | None = None, sweep_conn: PgConnection | None = None, + api_client: InternalAPIClient | None = None, ) -> None: self._lease = lease self._interval = ( @@ -177,6 +402,10 @@ def __init__( ) self._sweep_conn = sweep_conn self._owns_sweep_conn = sweep_conn is None + # Lazily built so the reaper can be constructed without env/HTTP set up + # (tests inject a fake). Recovery marks execution ERROR via this client — + # business state goes through the internal API, not direct DB. + self._api_client = api_client self._running = False self._is_leader = False # Liveness heartbeat: monotonic timestamp of the last tick start. A @@ -206,6 +435,15 @@ def _get_sweep_conn(self) -> PgConnection: self._sweep_conn = create_pg_connection(env_prefix="DB_") return self._sweep_conn + def _get_api_client(self) -> InternalAPIClient: + # Lazy import + build: keeps reaper construction free of HTTP/env (an + # injected fake short-circuits this), and avoids a module-load import cycle. + if self._api_client is None: + from shared.api import InternalAPIClient + + self._api_client = InternalAPIClient() + return self._api_client + def _discard_owned_sweep_conn(self) -> None: # After a sweep error, drop an owned connection so the next tick # reconnects — covers a poisoned (aborted-txn) or dead-socket handle that @@ -244,7 +482,9 @@ def tick(self) -> TickOutcome: if not self._is_leader: return TickOutcome(was_leader=False, reclaimed=0) try: - reclaimed = len(sweep_expired_barriers(self._get_sweep_conn())) + reclaimed = len( + recover_expired_barriers(self._get_sweep_conn(), self._get_api_client()) + ) except Exception: self._discard_owned_sweep_conn() raise diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index 238bbeeabc..a4fca8b146 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -155,6 +155,16 @@ def _row(conn, execution_id): return cur.fetchone() +def _org(conn, execution_id): + with conn.cursor() as cur: + cur.execute( + "SELECT organization_id FROM pg_barrier_state WHERE execution_id = %s", + (execution_id,), + ) + row = cur.fetchone() + return row[0] if row else None + + class TestPgBarrierEnqueue: def test_upsert_creates_row_and_attaches_links(self, barrier_db): tasks = [_mock_header_task() for _ in range(3)] @@ -191,8 +201,10 @@ def test_upsert_overwrites_stale_state(self, barrier_db): with barrier_db.cursor() as cur: cur.execute( "INSERT INTO pg_barrier_state " - "(execution_id, remaining, results, created_at, expires_at) " - "VALUES ('exec-R', 1, '[1,2]'::jsonb, now(), now() + interval '1h')" + "(execution_id, organization_id, remaining, results, " + " created_at, expires_at) " + "VALUES ('exec-R', '', 1, '[1,2]'::jsonb, now(), " + " now() + interval '1h')" ) task, _ = _mock_header_task() PgBarrier().enqueue( @@ -204,6 +216,46 @@ def test_upsert_overwrites_stale_state(self, barrier_db): ) assert _row(barrier_db, "exec-R") == (2, []) + def test_enqueue_stamps_organization_id(self, barrier_db): + # The whole reason the org column + migration exist (reaper recovery). + PgBarrier().enqueue( + [_mock_header_task()[0]], + callback_task_name="cb", + callback_kwargs={"execution_id": "exec-ORG", "organization_id": "org-42"}, + callback_queue="general", + app_instance=None, + ) + assert _org(barrier_db, "exec-ORG") == "org-42" + + def test_enqueue_defaults_org_to_empty_when_absent(self, barrier_db): + PgBarrier().enqueue( + [_mock_header_task()[0]], + callback_task_name="cb", + callback_kwargs={"execution_id": "exec-NOORG"}, # no organization_id + callback_queue="general", + app_instance=None, + ) + assert _org(barrier_db, "exec-NOORG") == "" + + def test_upsert_refreshes_org_on_reenqueue(self, barrier_db): + # Exercises the ON CONFLICT DO UPDATE SET organization_id clause. + with barrier_db.cursor() as cur: + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, organization_id, remaining, results, " + " created_at, expires_at) " + "VALUES ('exec-REORG', 'old-org', 1, '[]'::jsonb, now(), " + " now() + interval '1h')" + ) + PgBarrier().enqueue( + [_mock_header_task()[0]], + callback_task_name="cb", + callback_kwargs={"execution_id": "exec-REORG", "organization_id": "new-org"}, + callback_queue="general", + app_instance=None, + ) + assert _org(barrier_db, "exec-REORG") == "new-org" + def test_mid_loop_dispatch_failure_deletes_row(self, barrier_db): good, _ = _mock_header_task() bad, bad_cloned = _mock_header_task() @@ -223,8 +275,9 @@ def _seed(conn, execution_id, remaining, *, results="[]"): with conn.cursor() as cur: cur.execute( "INSERT INTO pg_barrier_state " - "(execution_id, remaining, results, created_at, expires_at) " - "VALUES (%s, %s, %s::jsonb, now(), now() + interval '1h')", + "(execution_id, organization_id, remaining, results, " + " created_at, expires_at) " + "VALUES (%s, '', %s, %s::jsonb, now(), now() + interval '1h')", (execution_id, remaining, results), ) @@ -487,8 +540,9 @@ def test_expires_at_must_exceed_created_at(self, barrier_db): with barrier_db.cursor() as cur: cur.execute( "INSERT INTO pg_barrier_state " - "(execution_id, remaining, results, created_at, expires_at) " - "VALUES ('bad', 1, '[]'::jsonb, now(), now())" # expires == created + "(execution_id, organization_id, remaining, results, " + " created_at, expires_at) " + "VALUES ('bad', '', 1, '[]'::jsonb, now(), now())" # expires==created ) diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py index a37db4494a..ed89fff2ef 100644 --- a/workers/tests/test_pg_reaper.py +++ b/workers/tests/test_pg_reaper.py @@ -18,6 +18,7 @@ import os import threading import time +from types import SimpleNamespace from unittest.mock import MagicMock, patch import psycopg2 @@ -27,7 +28,7 @@ from queue_backend.pg_queue.reaper import ( PgReaper, reaper_interval_from_env, - sweep_expired_barriers, + recover_expired_barriers, ) # --- Layer 1: env + construction (no DB) --- @@ -99,12 +100,16 @@ def test_valid_interval_accepted(self): class TestLeadershipGating: def _reaper(self, lease): - return PgReaper(lease, interval_seconds=0.01, sweep_conn=object()) + # Inject dummy sweep_conn + api_client so a tick doesn't build real ones; + # recover_expired_barriers is patched in each test, so neither is used. + return PgReaper( + lease, interval_seconds=0.01, sweep_conn=object(), api_client=object() + ) def test_sweeps_when_leader(self): reaper = self._reaper(_FakeLease(acquires=True, renews=True)) with patch.object( - reaper_mod, "sweep_expired_barriers", return_value=["x"] + reaper_mod, "recover_expired_barriers", return_value=["x"] ) as sweep: outcome = reaper.tick() # acquires leadership → sweeps assert outcome.was_leader is True @@ -114,7 +119,7 @@ def test_sweeps_when_leader(self): def test_standby_does_not_sweep(self): reaper = self._reaper(_FakeLease(acquires=False)) # can't get the lease - with patch.object(reaper_mod, "sweep_expired_barriers") as sweep: + with patch.object(reaper_mod, "recover_expired_barriers") as sweep: outcome = reaper.tick() assert outcome == (False, 0) assert reaper.is_leader is False @@ -124,7 +129,7 @@ def test_steps_down_when_renew_fails(self): # tick 1 acquires; tick 2 renew fails → step down, acquire also fails → # standby. Driven through ticks, no private-flag poking. reaper = self._reaper(_FakeLease(acquires=[True, False], renews=[False])) - with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): assert reaper.tick().was_leader is True assert reaper.tick().was_leader is False assert reaper.is_leader is False @@ -132,7 +137,7 @@ def test_steps_down_when_renew_fails(self): def test_steps_down_then_reacquires(self): # leader → lose the lease one cycle → re-acquire the next and resume. reaper = self._reaper(_FakeLease(acquires=[True, False, True], renews=[False])) - with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): assert reaper.tick().was_leader is True # acquired assert reaper.tick().was_leader is False # renew failed → standby assert reaper.tick().was_leader is True # re-acquired @@ -141,7 +146,7 @@ def test_steps_down_then_reacquires(self): def test_renew_raising_steps_down(self): lease = _FakeLease(acquires=True, renews=True) reaper = self._reaper(lease) - with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): reaper.tick() # becomes leader lease.renew = MagicMock(side_effect=psycopg2.OperationalError("boom")) with pytest.raises(psycopg2.OperationalError): @@ -151,7 +156,7 @@ def test_renew_raising_steps_down(self): def test_release_on_stop_when_leader(self): lease = _FakeLease(acquires=True, renews=True) reaper = self._reaper(lease) - with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): t = threading.Thread(target=reaper.run, kwargs={"install_signals": False}) t.start() time.sleep(0.05) @@ -179,26 +184,75 @@ def boom(): # --- Layer 3: connection handling (mocked, no DB) --- -class TestSweepConnection: - def test_sql_contract(self): +class _FakeApiClient: + """Models the real ``InternalAPIClient`` contract the reaper depends on. + + ``get_workflow_execution`` returns a response with ``success``/``status`` + (the real client returns ``success=False`` on any error instead of raising); + ``fail_read`` models that. ``fail_update`` makes the ERROR-mark raise (the + real ``update_*`` does raise). ``fail_update_for`` fails only specific ids. + """ + + def __init__( + self, status="EXECUTING", *, fail_read=False, fail_update=False, + fail_update_for=None, on_get=None, + ): + self._status = status + self._fail_read = fail_read + self._fail_update = fail_update + self._fail_update_for = set(fail_update_for or []) + self._on_get = on_get # side-effect hook (e.g. re-arm the row mid-recovery) + self.get_calls: list = [] + self.update_calls: list = [] + + def get_workflow_execution( + self, execution_id, organization_id=None, file_execution=True, **kw + ): + self.get_calls.append((execution_id, organization_id, file_execution)) + if self._on_get is not None: + self._on_get(execution_id) + if self._fail_read: + return SimpleNamespace(success=False, status=None) # real error contract + return SimpleNamespace(success=True, status=self._status) + + def update_workflow_execution_status( + self, execution_id, status, error_message=None, organization_id=None, **kw + ): + self.update_calls.append( + SimpleNamespace( + execution_id=execution_id, + status=status, + error_message=error_message, + organization_id=organization_id, + ) + ) + if self._fail_update or execution_id in self._fail_update_for: + raise RuntimeError("api down") + return {"status": "updated"} + + +class TestRecoverConnection: + def test_select_sql_contract(self): cur = MagicMock() - cur.fetchall.return_value = [("e1",), ("e2",)] + cur.fetchall.return_value = [] # nothing expired conn = MagicMock() conn.cursor.return_value.__enter__.return_value = cur - assert sweep_expired_barriers(conn) == ["e1", "e2"] + api = MagicMock() + assert recover_expired_barriers(conn, api) == [] sql = cur.execute.call_args[0][0] - assert "DELETE FROM pg_barrier_state" in sql + assert "SELECT" in sql and "pg_barrier_state" in sql + assert "organization_id" in sql and "remaining" in sql assert "expires_at < now()" in sql - assert "RETURNING execution_id" in sql conn.commit.assert_called_once() + api.update_workflow_execution_status.assert_not_called() - def test_rolls_back_on_error(self): + def test_rolls_back_on_select_error(self): cur = MagicMock() cur.execute.side_effect = psycopg2.OperationalError("dead") conn = MagicMock() conn.cursor.return_value.__enter__.return_value = cur with pytest.raises(psycopg2.OperationalError): - sweep_expired_barriers(conn) + recover_expired_barriers(conn, MagicMock()) conn.rollback.assert_called_once() conn.commit.assert_not_called() @@ -222,10 +276,14 @@ def test_failed_sweep_discards_owned_conn(self, monkeypatch): monkeypatch.setattr( reaper_mod, "create_pg_connection", MagicMock(return_value=conn) ) - reaper = PgReaper(_FakeLease(acquires=True, renews=True), interval_seconds=1) + reaper = PgReaper( + _FakeLease(acquires=True, renews=True), + interval_seconds=1, + api_client=object(), # injected so tick() doesn't build a real client + ) with patch.object( reaper_mod, - "sweep_expired_barriers", + "recover_expired_barriers", side_effect=psycopg2.OperationalError("x"), ): with pytest.raises(psycopg2.OperationalError): @@ -240,9 +298,9 @@ def test_failed_sweep_discards_owned_conn(self, monkeypatch): def _new_conn(): os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") # Manual-commit — exactly as the production reaper opens it - # (create_pg_connection default). NOT autocommit: that would make - # sweep_expired_barriers' own commit() a no-op and its rollback unreachable, - # so Layer 4 would test a different mode than the real reaper runs in. + # (create_pg_connection default). NOT autocommit: that would make the + # recover_expired_barriers commit a no-op and its rollback unreachable, so + # Layer 4 would test a different mode than the real reaper runs in. return create_pg_connection(env_prefix="TEST_DB_") @@ -258,35 +316,34 @@ def barrier_conn(): conn.close() pytest.skip("pg_barrier_state migration not applied (run backend migrate)") cur.execute("DELETE FROM pg_barrier_state") + cur.execute("DELETE FROM pg_batch_dedup") conn.commit() yield conn with conn.cursor() as cur: cur.execute("DELETE FROM pg_barrier_state") + cur.execute("DELETE FROM pg_batch_dedup") conn.commit() conn.close() -def _seed(conn, execution_id, *, expired): +def _seed(conn, execution_id, *, expired, organization_id="org-1", remaining=1): # created_at must precede expires_at (CheckConstraint # pg_barrier_expires_after_created). Commit so the seed is durable like a # real barrier row (written by PgBarrier in another transaction) — and so the - # manual-commit sweep's own commit() is what persists the DELETE. + # manual-commit recovery's own commit is what persists the DELETE. + created_sql, expires_sql = ( + ("now() - interval '2 hours'", "now() - interval '1 hour'") + if expired + else ("now()", "now() + interval '6 hours'") + ) with conn.cursor() as cur: - if expired: - cur.execute( - "INSERT INTO pg_barrier_state " - "(execution_id, remaining, results, created_at, expires_at) " - "VALUES (%s, 1, '[]'::jsonb, now() - interval '2 hours', " - " now() - interval '1 hour')", - (execution_id,), - ) - else: - cur.execute( - "INSERT INTO pg_barrier_state " - "(execution_id, remaining, results, created_at, expires_at) " - "VALUES (%s, 1, '[]'::jsonb, now(), now() + interval '6 hours')", - (execution_id,), - ) + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, organization_id, remaining, results, " + " created_at, expires_at) " + f"VALUES (%s, %s, %s, '[]'::jsonb, {created_sql}, {expires_sql})", + (execution_id, organization_id, remaining), + ) conn.commit() @@ -298,28 +355,163 @@ def _ids(conn): return rows -class TestSweepExpiredBarriers: - def test_reclaims_only_expired(self, barrier_conn): - _seed(barrier_conn, "exp-1", expired=True) - _seed(barrier_conn, "exp-2", expired=True) +def _dedup_count(conn, execution_id): + with conn.cursor() as cur: + cur.execute( + "SELECT count(*) FROM pg_batch_dedup WHERE execution_id = %s", + (execution_id,), + ) + n = cur.fetchone()[0] + conn.commit() + return n + + +class TestRecoverExpiredBarriers: + def test_recovers_only_expired_marks_error_and_cleans_up(self, barrier_conn): + _seed(barrier_conn, "exp-1", expired=True, remaining=2) _seed(barrier_conn, "fresh-1", expired=False) - reclaimed = sweep_expired_barriers(barrier_conn) - assert sorted(reclaimed) == ["exp-1", "exp-2"] + api = _FakeApiClient(status="EXECUTING") + recovered = recover_expired_barriers(barrier_conn, api) + assert recovered == ["exp-1"] + (call,) = api.update_calls # exactly one execution marked + assert call.execution_id == "exp-1" + assert call.status == "ERROR" + assert call.organization_id == "org-1" + assert "never completed" in call.error_message # remaining>0 assert _ids(barrier_conn) == ["fresh-1"] # fresh barrier untouched + def test_remaining_zero_uses_callback_stranded_message(self, barrier_conn): + _seed(barrier_conn, "exp-0", expired=True, remaining=0) + api = _FakeApiClient(status="EXECUTING") + recover_expired_barriers(barrier_conn, api) + (call,) = api.update_calls + assert "callback never fired" in call.error_message + + def test_skips_already_terminal_execution(self, barrier_conn): + # A remaining==0 expired row can belong to a COMPLETED exec whose row + # delete failed — must NOT overwrite it to ERROR. + _seed(barrier_conn, "exp-done", expired=True, remaining=0) + api = _FakeApiClient(status="COMPLETED") + recovered = recover_expired_barriers(barrier_conn, api) + assert recovered == ["exp-done"] # cleaned up + assert api.update_calls == [] # no status overwrite + assert _ids(barrier_conn) == [] + + def test_org_missing_leaves_row_and_skips_mark(self, barrier_conn): + # No org → can't call the org-scoped API; LEAVE the row (don't erase the + # only recovery handle) and don't mark. + _seed(barrier_conn, "exp-noorg", expired=True, organization_id="") + api = _FakeApiClient() + recovered = recover_expired_barriers(barrier_conn, api) + assert recovered == [] # not recovered + assert api.get_calls == [] and api.update_calls == [] # can't call org API + assert _ids(barrier_conn) == ["exp-noorg"] # row preserved for ops + + def test_failed_status_read_does_not_mark_and_retains_row(self, barrier_conn): + # [Critical] the real client returns success=False (not raises) on a blip; + # a failed read must NOT fall through to ERROR (would corrupt a COMPLETED + # exec) — leave the row for the next sweep. + _seed(barrier_conn, "exp-readfail", expired=True, remaining=0) + api = _FakeApiClient(fail_read=True) + recovered = recover_expired_barriers(barrier_conn, api) + assert recovered == [] + assert api.update_calls == [] # never marked ERROR on an unconfirmed status + assert _ids(barrier_conn) == ["exp-readfail"] # retained for retry + + def test_status_read_passes_file_execution_false(self, barrier_conn): + _seed(barrier_conn, "exp-fe", expired=True) + api = _FakeApiClient(status="EXECUTING") + recover_expired_barriers(barrier_conn, api) + # Exactly one status read, recorded as exec-id / org / file_execution. + # The reaper must skip the costly file-execution fetch it doesn't need. + [(_exec_id, _org, file_execution)] = api.get_calls + assert file_execution is False + + def test_api_failure_leaves_row_for_retry(self, barrier_conn): + _seed(barrier_conn, "exp-fail", expired=True) + api = _FakeApiClient(status="EXECUTING", fail_update=True) + recovered = recover_expired_barriers(barrier_conn, api) + assert recovered == [] # not recovered + assert _ids(barrier_conn) == ["exp-fail"] # row left for next sweep + + def test_one_failing_execution_does_not_block_others(self, barrier_conn): + # Per-execution isolation: a mid-loop failure rolls back + the loop + # continues; the others still recover. + for eid in ("exp-a", "exp-bad", "exp-c"): + _seed(barrier_conn, eid, expired=True) + api = _FakeApiClient(status="EXECUTING", fail_update_for=["exp-bad"]) + recovered = recover_expired_barriers(barrier_conn, api) + assert sorted(recovered) == ["exp-a", "exp-c"] # the two good ones + assert _ids(barrier_conn) == ["exp-bad"] # only the failing row remains + + def test_rearmed_execution_is_not_marked_error(self, barrier_conn): + # greptile #2070: if the same execution_id is re-enqueued (expires_at + # reset to the future) between the sweep SELECT and the mark, the reaper + # must NOT mark the freshly-running execution ERROR. Simulate the re-arm + # via the status-read side-effect. + _seed(barrier_conn, "exp-rearm", expired=True) + + def rearm(execution_id): + with barrier_conn.cursor() as cur: + cur.execute( + "UPDATE pg_barrier_state SET expires_at = now() + interval '6 hours' " + "WHERE execution_id = %s", + (execution_id,), + ) + barrier_conn.commit() + + api = _FakeApiClient(status="EXECUTING", on_get=rearm) + recovered = recover_expired_barriers(barrier_conn, api) + assert recovered == [] # not recovered + assert api.update_calls == [] # the live re-run was NOT marked ERROR + assert _ids(barrier_conn) == ["exp-rearm"] # re-armed row left intact + + def test_status_none_on_success_does_not_mark(self, barrier_conn): + # A successful read with no status is anomalous — don't mark on it. + _seed(barrier_conn, "exp-nostatus", expired=True) + api = _FakeApiClient(status=None) # success=True, status=None + recovered = recover_expired_barriers(barrier_conn, api) + assert recovered == [] + assert api.update_calls == [] + assert _ids(barrier_conn) == ["exp-nostatus"] # left for next sweep + + def test_all_skipped_sweep_does_not_log_systemic_error(self, barrier_conn, caplog): + # org-missing rows are benign skips, not failures — they must NOT trigger + # the systemic "recovered NONE / API down" ERROR escalation. + import logging + + _seed(barrier_conn, "exp-noorg", expired=True, organization_id="") + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.reaper"): + recover_expired_barriers(barrier_conn, _FakeApiClient()) + assert not any( + "systemic" in r.message for r in caplog.records + ), "all-skipped sweep should not escalate to a systemic-failure error" + + def test_reclaims_dedup_markers(self, barrier_conn): + _seed(barrier_conn, "exp-d", expired=True) + with barrier_conn.cursor() as cur: + cur.execute( + "INSERT INTO pg_batch_dedup (execution_id, batch_index, created_at) " + "VALUES ('exp-d', 0, now())" + ) + barrier_conn.commit() + recover_expired_barriers(barrier_conn, _FakeApiClient(status="EXECUTING")) + assert _dedup_count(barrier_conn, "exp-d") == 0 + def test_noop_when_nothing_expired(self, barrier_conn): _seed(barrier_conn, "fresh-1", expired=False) - assert sweep_expired_barriers(barrier_conn) == [] + assert recover_expired_barriers(barrier_conn, _FakeApiClient()) == [] assert _ids(barrier_conn) == ["fresh-1"] - def test_tick_sweeps_via_real_conn(self, barrier_conn): + def test_tick_recovers_via_real_conn(self, barrier_conn): _seed(barrier_conn, "exp-1", expired=True) reaper = PgReaper( _FakeLease(acquires=True, renews=True), interval_seconds=1, sweep_conn=barrier_conn, + api_client=_FakeApiClient(status="EXECUTING"), ) - outcome = reaper.tick() # became leader and reclaimed the orphan + outcome = reaper.tick() # became leader and recovered the orphan assert outcome.was_leader is True assert outcome.reclaimed == 1 assert _ids(barrier_conn) == [] @@ -396,7 +588,7 @@ def test_is_leader_reflected(self): interval_seconds=0.01, sweep_conn=object(), ) - with patch.object(reaper_mod, "sweep_expired_barriers", return_value=[]): + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): reaper.tick() # becomes leader server = self._server(reaper) try: From c2e6a6f8208b571f9237e0a0555a6700bbfd0e9b Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:34:43 +0530 Subject: [PATCH 20/65] =?UTF-8?q?UN-3570=20[FEAT]=20PG=20Queue=209e=20PR?= =?UTF-8?q?=203=20=E2=80=94=20Flipt=20canary=20wiring=20for=20transport=20?= =?UTF-8?q?resolution=20(#2071)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3570 [FEAT] PG Queue 9e PR 3 — Flipt canary wiring for transport resolution Replace the hardwired Celery result in resolve_transport() with a real decision: an env master-gate (PG_QUEUE_TRANSPORT_ENABLED, default off) that, when on, consults the Flipt boolean flag pg_queue_execution_enabled. Fails closed to Celery on any error. - entity_id = execution_id (per-execution sticky %-rollout; resolved once, carried in the task payload so an in-flight execution never re-buckets) - context carries org/workflow/pipeline ids (str-coerced — UUIDs in the gRPC map context would be swallowed as False and silently force Celery) for segment rollouts - both creation chokepoints wired: internal_api_views.create_workflow_execution (scheduler path) and workflow_helper.execute_workflow_async (API/manual/async) Gated off by default — no behaviour change until the flag is enabled, which requires PG consumers deployed first (deploy-ordering safety). Tests: 15 cases in test_transport.py (gate off/on, flag true/false, fail-closed, entity/context shape, UUID-coercion regression). Co-Authored-By: Claude Opus 4.8 * UN-3570 address review: type honesty, fail-closed org/Flipt guards, observability - Widen resolve_transport id params to `str | UUID` (callers pass UUIDs; the body already str-coerces) — resolves the SonarCloud type-mismatch and makes the contract honest. - Fail closed to celery when organization_id is missing (str(None) must never reach the Flipt org segment) — the helper path reads it from StateStore which can be empty. - Fail closed + loud warning when the gate is ON but FLIPT_SERVICE_AVAILABLE is not true, so a blind Flipt can't masquerade as a healthy 100%-celery canary. - Log the resolved transport on the gate-ON path (deliberate celery vs pg_queue vs blind are now distinguishable); drop the inaccurate "import-time fault" clause from the fail-closed comment. - Doc: organization_id is Organization.organization_id (X-Organization-ID), not the DB pk. - sample.env: document that pg_queue needs all three (gate + FLIPT_SERVICE_AVAILABLE + flag). - +2 tests (missing-org → celery, gate-on + Flipt-unavailable → celery). Co-Authored-By: Claude Opus 4.8 * UN-3570 address greptile: organization_id None type + FLIPT_SERVICE_AVAILABLE parse parity - organization_id annotation widened to `str | UUID | None` — the helper path passes UserContext.get_organization_identifier() which can be None at runtime; the existing `if not organization_id` guard handles it, so the type should admit None rather than mislead callers/static analysis. - FLIPT_SERVICE_AVAILABLE check now parses exactly like FliptClient (`.lower()`, no `.strip()`) so the two can't disagree on a whitespaced value like " true" (which would otherwise skip the "Flipt blind" warning). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/backend/settings/base.py | 8 + backend/sample.env | 7 + .../workflow_manager/internal_api_views.py | 12 +- .../workflow_v2/tests/test_transport.py | 139 ++++++++++++++-- .../workflow_manager/workflow_v2/transport.py | 154 +++++++++++++++--- .../workflow_v2/workflow_helper.py | 17 +- 6 files changed, 296 insertions(+), 41 deletions(-) diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index 787a7603e6..4b3fe0ce90 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -151,6 +151,14 @@ def get_required_setting(setting_key: str, default: str | None = None) -> str | CELERY_BACKEND_DB_NAME = os.environ.get("CELERY_BACKEND_DB_NAME") or DB_NAME DEFAULT_ORGANIZATION = "default_org" FLIPT_BASE_URL = os.environ.get("FLIPT_BASE_URL", "http://localhost:9005") +# 9e PG-queue transport master-gate (kill-switch). When not "true", +# resolve_transport() never consults Flipt and every workflow execution rides +# Celery — the instant global rollback AND the deploy-ordering safety (stays off +# until PG consumers are running in the fleet). See +# workers/queue_backend/pg_queue/9e-design.md §2. +PG_QUEUE_TRANSPORT_ENABLED = CommonUtils.str_to_bool( + os.environ.get("PG_QUEUE_TRANSPORT_ENABLED", "False") +) PLATFORM_HOST = os.environ.get("PLATFORM_SERVICE_HOST", "http://localhost") PLATFORM_PORT = os.environ.get("PLATFORM_SERVICE_PORT", 3001) PROMPT_HOST = os.environ.get("PROMPT_HOST", "http://localhost") diff --git a/backend/sample.env b/backend/sample.env index 377016fdec..80406dfcd4 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -184,6 +184,13 @@ TOOL_REGISTRY_CONFIG_PATH="/data/tool_registry_config" # Flipt Service FLIPT_SERVICE_AVAILABLE=False +# 9e PG-queue transport master-gate (kill-switch). Routing an execution onto the +# PG queue requires ALL THREE: this gate True, FLIPT_SERVICE_AVAILABLE=True (else +# the Flipt client returns False for every flag), and the Flipt flag +# `pg_queue_execution_enabled` on. Keep False until PG queue consumers are running +# in the fleet; set False for instant rollback. +PG_QUEUE_TRANSPORT_ENABLED=False + # File System Configuration for Workflow and API Execution # Directory Prefixes for storing execution files diff --git a/backend/workflow_manager/internal_api_views.py b/backend/workflow_manager/internal_api_views.py index ac81e31aa0..bdc09a0a50 100644 --- a/backend/workflow_manager/internal_api_views.py +++ b/backend/workflow_manager/internal_api_views.py @@ -323,10 +323,14 @@ def create_workflow_execution(request): # Resolve the transport this execution rides (9e). Decided once here, at # the creation chokepoint, and returned so the caller carries it in the - # dispatched task's payload — not persisted on the row. PR 1 always - # resolves "celery"; PR 3 wires Flipt (keyed on workflow/pipeline/org) in - # resolve_transport(). - transport = resolve_transport() + # dispatched task's payload — not persisted on the row. Flipt (gated by + # the env master-switch) decides; fails closed to "celery". + transport = resolve_transport( + execution_id=str(execution.id), + organization_id=org_id, + workflow_id=str(workflow.id), + pipeline_id=data.get("pipeline_id"), + ) return Response( { diff --git a/backend/workflow_manager/workflow_v2/tests/test_transport.py b/backend/workflow_manager/workflow_v2/tests/test_transport.py index 328850638e..2b0b100f63 100644 --- a/backend/workflow_manager/workflow_v2/tests/test_transport.py +++ b/backend/workflow_manager/workflow_v2/tests/test_transport.py @@ -1,18 +1,28 @@ -"""Tests for the 9e transport-resolution seam. +"""Tests for the 9e transport-resolution seam (PR 3 — Flipt canary wiring). -PR 1 is the *inert* seam: ``resolve_transport`` always returns Celery, so the -whole pipeline is byte-identical to today. These tests pin that contract so a -future change (PR 3, Flipt wiring) can't silently flip the default. +``resolve_transport`` is the single chokepoint that decides whether a new +execution rides the legacy Celery transport or the Postgres queue. It is gated +by an env master-switch and, when that is on, by a Flipt boolean flag; it fails +closed to Celery on any problem. These tests pin that contract. """ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +import pytest +from django.test import override_settings from unstract.core.data_models import ( DEFAULT_WORKFLOW_TRANSPORT, WorkflowTransport, normalize_transport, ) -from workflow_manager.workflow_v2.transport import resolve_transport + +from workflow_manager.workflow_v2.transport import ( + PG_QUEUE_FLAG_KEY, + resolve_transport, +) + +# Where ``check_feature_flag_status`` is looked up (imported into the module). +_FLIPT = "workflow_manager.workflow_v2.transport.check_feature_flag_status" class TestWorkflowTransportEnum: @@ -25,14 +35,119 @@ def test_default_is_celery(self): class TestResolveTransport: - def test_resolves_celery_in_pr1(self): - """PR 1: always Celery (inert seam, no inputs). PR 3 adds the - workflow/pipeline/org params + Flipt evaluation.""" - assert resolve_transport() == WorkflowTransport.CELERY.value - + @pytest.fixture(autouse=True) + def _flipt_available(self, monkeypatch): + """The gate-ON path short-circuits to celery when Flipt is marked + unavailable; default it available so the flag-evaluation tests exercise + the real path. Individual tests override as needed.""" + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=False) + def test_master_gate_off_never_consults_flipt(self): + """Master-gate off → Celery, and Flipt is not even called.""" + with patch(_FLIPT) as flipt: + result = resolve_transport(execution_id="e1", organization_id="org1") + assert result == WorkflowTransport.CELERY.value + flipt.assert_not_called() + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) + def test_missing_organization_forces_celery(self): + """No org context → can't segment safely → fail closed; Flipt not called + (str(None)/"" must never reach the Flipt org segment).""" + with patch(_FLIPT) as flipt: + result = resolve_transport(execution_id="e1", organization_id="") + assert result == WorkflowTransport.CELERY.value + flipt.assert_not_called() + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) + def test_gate_on_but_flipt_unavailable_forces_celery(self, monkeypatch): + """Gate ON + Flipt service unavailable → celery, loudly — not a silent + masquerade as a healthy 100%-celery canary.""" + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") + with patch(_FLIPT) as flipt: + result = resolve_transport(execution_id="e1", organization_id="org1") + assert result == WorkflowTransport.CELERY.value + flipt.assert_not_called() + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) + def test_gate_on_flipt_true_resolves_pg_queue(self): + with patch(_FLIPT, return_value=True): + result = resolve_transport(execution_id="e1", organization_id="org1") + assert result == WorkflowTransport.PG_QUEUE.value + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) + def test_gate_on_flipt_false_resolves_celery(self): + with patch(_FLIPT, return_value=False): + result = resolve_transport(execution_id="e1", organization_id="org1") + assert result == WorkflowTransport.CELERY.value + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) + def test_flipt_exception_fails_closed_to_celery(self): + """A Flipt outage must never break execution creation.""" + with patch(_FLIPT, side_effect=RuntimeError("flipt down")): + result = resolve_transport(execution_id="e1", organization_id="org1") + assert result == WorkflowTransport.CELERY.value + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) + def test_passes_execution_id_as_entity_and_builds_context(self): + """entity_id = execution_id (sticky bucketing); context carries the + org/workflow/pipeline for segment rules. + """ + with patch(_FLIPT, return_value=True) as flipt: + resolve_transport( + execution_id="exec-42", + organization_id="org1", + workflow_id="wf1", + pipeline_id="pl1", + ) + flipt.assert_called_once_with( + flag_key=PG_QUEUE_FLAG_KEY, + entity_id="exec-42", + context={ + "organization_id": "org1", + "workflow_id": "wf1", + "pipeline_id": "pl1", + }, + ) + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) + def test_non_string_ids_are_coerced_for_flipt(self): + """Callers pass UUID objects for the ids. Flipt's context is a gRPC + map and entity_id must hash stably, so every value must + reach check_feature_flag_status as a plain str — otherwise the client + swallows the serialization error as False and silently forces celery. + Regression test for that exact dev-test finding. + """ + import uuid + + ex = uuid.UUID("8c091789-9dde-45e7-bb85-06f23fe120eb") + wf = uuid.UUID("ebed2834-c9fb-4b6c-8df3-9dd841f616bb") + pl = uuid.UUID("eaca3b0e-083a-4c75-8b25-85349d54145b") + with patch(_FLIPT, return_value=True) as flipt: + result = resolve_transport( + execution_id=ex, organization_id="org1", workflow_id=wf, pipeline_id=pl + ) + assert result == WorkflowTransport.PG_QUEUE.value + _, kwargs = flipt.call_args + assert kwargs["entity_id"] == str(ex) + assert all(isinstance(v, str) for v in kwargs["context"].values()) + assert kwargs["context"]["workflow_id"] == str(wf) + assert kwargs["context"]["pipeline_id"] == str(pl) + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) + def test_context_omits_unset_optional_ids(self): + with patch(_FLIPT, return_value=True) as flipt: + resolve_transport(execution_id="exec-42", organization_id="org1") + _, kwargs = flipt.call_args + assert kwargs["context"] == {"organization_id": "org1"} + + @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) def test_result_is_a_valid_transport_value(self): valid = {t.value for t in WorkflowTransport} - assert resolve_transport() in valid + with patch(_FLIPT, return_value=True): + assert ( + resolve_transport(execution_id="e1", organization_id="org1") in valid + ) class TestNormalizeTransport: diff --git a/backend/workflow_manager/workflow_v2/transport.py b/backend/workflow_manager/workflow_v2/transport.py index dfb22a181e..9b0796fd06 100644 --- a/backend/workflow_manager/workflow_v2/transport.py +++ b/backend/workflow_manager/workflow_v2/transport.py @@ -8,33 +8,149 @@ row: the payload is the single carrier, durable for PG via the queue row's JSONB, and the giant shared table is never migrated for this work. -PR 1 (this seam) hardwires the result to Celery, so behaviour is byte-identical -to today. PR 3 replaces the body with a Flipt evaluation (percentage rollout via -``entity_id`` hashing + per-org segment via ``context``), wrapped by an env -kill-switch and failing closed to Celery. +PR 3 (this change) replaces PR 1's hardwired Celery with a Flipt evaluation: + + master-gate (env) → Flipt boolean (``pg_queue_execution_enabled``) → transport + +Routing onto PG needs **all three** of: the env master-gate on, Flipt reachable +(``FLIPT_SERVICE_AVAILABLE=true``), and the flag enabled for this execution. + +- **Master-gate** (``settings.PG_QUEUE_TRANSPORT_ENABLED``, default off): until + ops flips it on, Flipt is never consulted and every execution rides Celery. + This is both the instant global kill-switch *and* the deploy-ordering safety — + the flag stays inert until PG consumers are actually running in the fleet. +- **Flipt** decides per-execution: ``entity_id = execution_id`` drives the + percentage-rollout hashing (an execution resolves exactly once, so it can + never re-bucket mid-flight); ``context`` carries org/workflow/pipeline for + segment rules. The flag contract is fixed in 9e-design §2. +- **Fail-closed to Celery**: a Flipt outage must never break execution creation, + mirroring ``normalize_transport`` on the read side. The gate-ON path logs its + decision so a "gate on but still all Celery" situation (e.g. a blind Flipt) + is visible rather than silent. """ from __future__ import annotations +import logging +import os +from typing import TYPE_CHECKING + +from django.conf import settings + from unstract.core.data_models import WorkflowTransport +from unstract.flags.feature_flag import check_feature_flag_status +if TYPE_CHECKING: + from uuid import UUID -def resolve_transport() -> str: +logger = logging.getLogger(__name__) + +# The fixed Flipt flag contract (9e-design §2): Boolean, default false. +PG_QUEUE_FLAG_KEY = "pg_queue_execution_enabled" + + +def resolve_transport( + *, + execution_id: str | UUID, + organization_id: str | UUID | None, + workflow_id: str | UUID | None = None, + pipeline_id: str | UUID | None = None, +) -> str: """Resolve the transport for a new workflow execution. + The id parameters are typed ``str | UUID`` because callers pass either + (e.g. the view path pre-coerces with ``str(...)``, the helper path passes + raw UUIDs). Coercion to the wire types Flipt needs is kept internal here so + callers never have to know about it. + + Args: + execution_id: The execution's id. Used as the Flipt ``entity_id`` so the + percentage rollout buckets per execution and stays sticky (one + execution is resolved exactly once). + organization_id: The org's string identifier + (``Organization.organization_id`` — the ``X-Organization-ID`` header + value, *not* the DB pk). May be ``None`` on the helper path + (``StateStore`` not populated) → resolves to celery. Carried in the + Flipt ``context`` for per-org segment rollouts. + workflow_id: Optional, carried in ``context`` for future segment rules. + pipeline_id: Optional, carried in ``context`` for future segment rules. + Returns: - The transport value (a :class:`WorkflowTransport` value string). - - Note: - PR 1 always returns ``"celery"`` and takes no arguments (the inert seam - needs no inputs). PR 3 reintroduces ``workflow_id`` / ``pipeline_id`` / - ``organization_id`` parameters when it wires Flipt here — percentage - rollout via ``entity_id`` hashing + per-org segment via ``context`` — - and updates the two call sites (``internal_api_views`` view and - ``workflow_helper.execute_workflow_async``). PR 3 must wrap the Flipt - evaluation in ``try/except`` and fall back to - ``WorkflowTransport.CELERY.value`` (fail-closed), so a Flipt outage can - never break execution creation — mirroring ``normalize_transport`` on - the read side. + A :class:`WorkflowTransport` value string — ``"pg_queue"`` only when the + master-gate is on, Flipt is reachable, and Flipt says yes for this + execution; ``"celery"`` otherwise (including any error — fail-closed). """ - return WorkflowTransport.CELERY.value + celery = WorkflowTransport.CELERY.value + + # Master-gate: until ops sets PG_QUEUE_TRANSPORT_ENABLED=true, never consult + # Flipt — every execution rides Celery (kill-switch + deploy-ordering safety). + # Intentionally unlogged: this is the steady state for every execution while + # the gate is off, so a log here would be pure noise. + if not settings.PG_QUEUE_TRANSPORT_ENABLED: + return celery + + # Gate is ON (canary/rollout). From here the decision is logged so a + # "gate on but everything still Celery" situation cannot hide. + + # No org context → per-org segment matching can't be trusted (str(None) would + # ship a bogus "None" org into the Flipt context and mis-segment). The view + # path validates the header non-None, but the helper path reads it from + # StateStore, which can be empty. Fail closed rather than mis-bucket. + if not organization_id: + logger.warning( + "resolve_transport: no organization_id for execution %s; forcing celery", + execution_id, + ) + return celery + + # FliptClient returns False for ALL flags when the service is marked + # unavailable — indistinguishable from "rollout says no". Surface it loudly so + # a blind Flipt under an ON gate doesn't masquerade as a healthy 100%-Celery + # canary. Parse exactly as FliptClient does (``.lower()``, no ``.strip()``) + # so the two can never disagree on a value like ``" true"``. + if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": + logger.warning( + "resolve_transport: gate ON but FLIPT_SERVICE_AVAILABLE != true " + "(Flipt is blind) for execution %s; forcing celery", + execution_id, + ) + return celery + + # Flipt's context is a gRPC map; callers pass UUID objects for + # the ids, so coerce every value to str. A non-str value makes the client's + # serialization fail and check_feature_flag_status swallow it as False — + # silently forcing celery. entity_id is str-coerced for the same reason (and + # so the %-rollout hash is stable across str/UUID call sites). + context = {"organization_id": str(organization_id)} + if workflow_id: + context["workflow_id"] = str(workflow_id) + if pipeline_id: + context["pipeline_id"] = str(pipeline_id) + + # Defense in depth: check_feature_flag_status already wraps its body in + # try/except → False, but an explicit fail-closed wrap here keeps a future + # change to that helper from ever letting a Flipt problem break execution + # creation. + try: + enabled = check_feature_flag_status( + flag_key=PG_QUEUE_FLAG_KEY, + entity_id=str(execution_id), + context=context, + ) + except Exception: + logger.warning( + "resolve_transport: Flipt evaluation failed for execution %s; " + "falling back to Celery", + execution_id, + exc_info=True, + ) + return celery + + result = WorkflowTransport.PG_QUEUE.value if enabled else celery + logger.info( + "resolve_transport: execution %s resolved to %s (flipt enabled=%s)", + execution_id, + result, + enabled, + ) + return result diff --git a/backend/workflow_manager/workflow_v2/workflow_helper.py b/backend/workflow_manager/workflow_v2/workflow_helper.py index 29a067a306..416373c9c1 100644 --- a/backend/workflow_manager/workflow_v2/workflow_helper.py +++ b/backend/workflow_manager/workflow_v2/workflow_helper.py @@ -510,17 +510,22 @@ def execute_workflow_async( log_events_id = StateStore.get(Common.LOG_EVENTS_ID) # Resolve the transport this execution rides (9e) and carry it in the # task payload — the pipeline reads it to stay on one transport - # end-to-end. PR 1 always resolves "celery" (no behaviour change). + # end-to-end. Flipt (gated by the env master-switch) decides; fails + # closed to "celery". # # NOTE (deliberate, two resolution sites): transport is resolved here # for the API/manual/async paths and separately in # internal_api_views.create_workflow_execution for the scheduler # path. These are DISTINCT entry paths — never a double-resolution of - # the same execution — so today they cannot diverge. PR 3 makes each - # an independent Flipt evaluation; before then, both must be funnelled - # through a single per-execution chokepoint (so a percentage re-roll - # can't split one execution across transports). Tracked for PR 3. - transport = resolve_transport() + # the same execution. entity_id is execution_id, so each execution + # buckets exactly once and can't split across transports even if the + # rollout % is re-rolled between the two sites. + transport = resolve_transport( + execution_id=execution_id, + organization_id=org_schema, + workflow_id=workflow_id, + pipeline_id=pipeline_id, + ) async_execution: AsyncResult = celery_app.send_task( "async_execute_bin", args=[ From 76f39d2861aa0cbf63f9c0949475110c89d54838 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:14:28 +0530 Subject: [PATCH 21/65] =?UTF-8?q?UN-3574=20[FEAT]=20PG=20Queue=209e=202d?= =?UTF-8?q?=20=E2=80=94=20orchestrator=20(async=5Fexecute=5Fbin)=20on=20PG?= =?UTF-8?q?=20+=20shared=20TaskPayload=20contract=20(#2072)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3574 [FEAT] PG Queue 9e 2d — orchestrator (async_execute_bin) on PG + shared TaskPayload Route the orchestrator task onto the PG queue when transport==pg_queue, so a pg_queue execution's orchestrate → fan-out → barrier → callback all run on PG (was hybrid: orchestrator on Celery, only fan-out/callback on PG). - Promote TaskPayload + FairnessPayload (the PG-message wire contract) to unstract.core; workers re-export them so existing imports keep working and the backend producer + worker consumer share one definition. - Backend PG producer (pg_queue/producer.py): enqueue a TaskPayload row to pg_queue_message via the ORM, mirroring the workers' PgQueueClient.send. UUIDs in args/kwargs are JSON-coerced (the message JSONField has no Django encoder). - Backend dispatch (execute_workflow_async): when transport==pg_queue, enqueue async_execute_bin to PG (general → "celery", api → "celery_api_deployments") instead of celery_app.send_task; task_id becomes "pg:". - Scheduler dispatch: pass backend=QueueBackend.PG when is_pg_transport(transport) (dispatch() already had the per-call override from 2a). Gated off by default — Celery path unchanged. Executor (tool run) + log workers stay Celery (PR B). Tests: 5 producer unit tests; workers regression suite green after the core move. Dev-tested end-to-end: a real API deployment ran async_execute_bin on the orchestrator PG consumer (not Celery), then fan-out → barrier → callback on PG to COMPLETED, clean teardown. Co-Authored-By: Claude Opus 4.8 * UN-3574 address review: reduce complexity, consolidate fairness contract, fix dispatch fork SonarCloud (cognitive complexity 18→<15) + review round on #2072: - Extract WorkflowHelper._dispatch_orchestrator_task — the PG-vs-Celery fork moves out of execute_workflow_async (complexity down) and becomes unit-testable. - HIGH: a `dispatched` flag — a post-dispatch bookkeeping failure no longer flips a running (already-enqueued) execution to ERROR; only pre-dispatch failures do. - HIGH: task_id is now bare `str(msg_id)` (was `pg:{msg_id}`) — one format across entry paths, matching the worker PgDispatchHandle.id. - Drop the dead Celery-only TimeoutError handler (manual poll never raised it; the generic handler covers any stray case). - Consolidate the fairness contract in unstract.core: WorkloadType (StrEnum) + FAIRNESS_MIN/MAX/DEFAULT_PRIORITY + FairnessPayload.workload_type Literal; workers re-export, backend producer + workflow_helper reference them (no more hand-built "api"/"non_api" literals or triplicated [1,10] bounds). - Producer: log+re-raise on enqueue failure (parity with worker _enqueue_pg); document the TaskPayload.queue field as diagnostic-only. Tests: +dispatch-fork suite (PG vs Celery, bare id, two org sentinels), +producer boundary/datetime/failure cases, +scheduler backend=PG override assertions. 34 backend + 99 workers green. Co-Authored-By: Claude Opus 4.8 * UN-3574 address sonar: use logging.exception() in the dispatch error handlers Both handlers in execute_workflow_async were `logger.error(..., exc_info=True)` inside `except Exception` → switch to `logger.exception(...)` (idiomatic, and exc_info is implicit). The post-dispatch branch drops the redundant `{error}` from the message since the traceback is now attached. Co-Authored-By: Claude Opus 4.8 * UN-3574 address greptile: drop redundant stack_info=True from logger.exception logger.exception() already attaches the active exception's traceback; stack_info additionally dumps the current call stack, producing a second overlapping stack trace per error in a direct except handler. Drop it. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/pg_queue/producer.py | 111 +++++++++++++ backend/pg_queue/tests/__init__.py | 0 backend/pg_queue/tests/test_producer.py | 113 +++++++++++++ .../tests/test_dispatch_orchestrator.py | 62 +++++++ .../workflow_v2/workflow_helper.py | 155 ++++++++++++------ .../core/src/unstract/core/data_models.py | 68 +++++++- workers/queue_backend/fairness.py | 54 +++--- .../queue_backend/pg_queue/task_payload.py | 24 +-- workers/scheduler/tasks.py | 7 +- .../test_dispatch_sites_characterisation.py | 26 +++ 10 files changed, 527 insertions(+), 93 deletions(-) create mode 100644 backend/pg_queue/producer.py create mode 100644 backend/pg_queue/tests/__init__.py create mode 100644 backend/pg_queue/tests/test_producer.py create mode 100644 backend/workflow_manager/workflow_v2/tests/test_dispatch_orchestrator.py diff --git a/backend/pg_queue/producer.py b/backend/pg_queue/producer.py new file mode 100644 index 0000000000..c4e9be5cf1 --- /dev/null +++ b/backend/pg_queue/producer.py @@ -0,0 +1,111 @@ +"""Backend-side producer for the PG queue (orchestrator dispatch — 9e PR A / 2d). + +The workers own the consumer and the worker-side ``dispatch()`` PG producer; the +backend has the ``pg_queue`` tables but, until now, no way to *enqueue* to them — +it dispatched the orchestrator (``async_execute_bin``) only via Celery. When an +execution rides the ``pg_queue`` transport, the orchestrator itself must run on +PG, so the backend enqueues it here. + +Same ``pg_queue_message`` row shape as the workers' ``PgQueueClient.send`` (a +:class:`~unstract.core.data_models.TaskPayload` JSONB), written via the +``PgQueueMessage`` ORM (whose Python-level field defaults supply ``now()`` / ``0`` +for the vt/counter columns). The ``TaskPayload`` / ``FairnessPayload`` wire +contract is shared via ``unstract.core`` so producer and consumer agree on the +keys without one codebase importing the other. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from pg_queue.models import PgQueueMessage +from unstract.core.data_models import ( + FAIRNESS_DEFAULT_PRIORITY, + FAIRNESS_MAX_PRIORITY, + FAIRNESS_MIN_PRIORITY, + FairnessPayload, + TaskPayload, +) + +logger = logging.getLogger(__name__) + +# Fairness L3 priority default, re-exported under the producer's name so callers +# (workflow_helper) import one symbol. Bounds + default are the single source of +# truth in unstract.core (shared with the workers' fairness/queue client); the DB +# CheckConstraint on pg_queue_message.priority is the writer-proof backstop. +DEFAULT_PRIORITY = FAIRNESS_DEFAULT_PRIORITY + +# Default/general queue name — mirrors the workers' ``QueueName.GENERAL = "celery"`` +# (the Celery default queue), used when the caller passes no explicit queue. +DEFAULT_GENERAL_QUEUE = "celery" + + +def _json_safe(value: Any) -> Any: + """Round-trip through JSON with ``default=str`` so UUIDs / datetimes in the + task args/kwargs become strings. + + ``PgQueueMessage.message`` is a plain ``JSONField`` (no Django encoder), and + the worker consumer already receives string ids on the existing PG dispatch + path, so coercing here keeps both transports consistent. + """ + return json.loads(json.dumps(value, default=str)) + + +def enqueue_task( + *, + task_name: str, + queue: str | None, + args: list[Any] | None = None, + kwargs: dict[str, Any] | None = None, + org_id: str = "", + priority: int = DEFAULT_PRIORITY, + fairness: FairnessPayload | None = None, +) -> int: + """Enqueue a task onto the PG queue; returns the new ``msg_id``. + + Mirrors ``queue_backend.pg_queue.client.PgQueueClient.send`` so the worker + consumer can decode and run it. A PG enqueue failure propagates — the caller + decides; for the orchestrator there is no silent Celery fallback (that would + hide the failure or risk a double-dispatch). + """ + if not FAIRNESS_MIN_PRIORITY <= priority <= FAIRNESS_MAX_PRIORITY: + raise ValueError( + f"priority out of range " + f"[{FAIRNESS_MIN_PRIORITY}, {FAIRNESS_MAX_PRIORITY}]: {priority!r}" + ) + pg_queue = queue or DEFAULT_GENERAL_QUEUE + message: TaskPayload = { + "task_name": task_name, + "args": _json_safe(list(args) if args is not None else []), + "kwargs": _json_safe(dict(kwargs) if kwargs is not None else {}), + "queue": pg_queue, + "fairness": fairness, + } + # Mirror the worker _enqueue_pg path: log the failure with breadcrumbs before + # it propagates, so a DB/constraint/serialization error isn't mislabeled by + # the caller's broad handler. + try: + row = PgQueueMessage.objects.create( + queue_name=pg_queue, + message=message, + org_id=org_id or "", + priority=priority, + ) + except Exception: + logger.exception( + "PG-queue: failed to enqueue task=%r queue=%r org=%r", + task_name, + pg_queue, + org_id, + ) + raise + logger.info( + "PG-queue: enqueued task=%r queue=%r msg_id=%s org=%r", + task_name, + pg_queue, + row.msg_id, + org_id, + ) + return row.msg_id diff --git a/backend/pg_queue/tests/__init__.py b/backend/pg_queue/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pg_queue/tests/test_producer.py b/backend/pg_queue/tests/test_producer.py new file mode 100644 index 0000000000..1d62f3b11e --- /dev/null +++ b/backend/pg_queue/tests/test_producer.py @@ -0,0 +1,113 @@ +"""Unit tests for the backend PG-queue producer (orchestrator dispatch, 9e PR A). + +DB-free: ``PgQueueMessage`` is mocked, so these pin the wire-shape contract and +the JSON-coercion logic without needing a test database. +""" + +import datetime +import uuid +from unittest.mock import MagicMock, patch + +import pytest + +from pg_queue import producer + +_MODEL = "pg_queue.producer.PgQueueMessage" + + +class TestEnqueueTask: + def test_builds_taskpayload_row(self): + with patch(_MODEL) as model: + model.objects.create.return_value = MagicMock(msg_id=4242) + msg_id = producer.enqueue_task( + task_name="async_execute_bin", + queue="celery_api_deployments", + args=["org", "wf", "exec"], + kwargs={"transport": "pg_queue"}, + org_id="org", + priority=5, + fairness={ + "org_id": "org", + "workload_type": "api", + "pipeline_priority": 5, + }, + ) + assert msg_id == 4242 + kw = model.objects.create.call_args.kwargs + assert kw["queue_name"] == "celery_api_deployments" + assert kw["org_id"] == "org" + assert kw["priority"] == 5 + msg = kw["message"] + assert msg["task_name"] == "async_execute_bin" + assert msg["queue"] == "celery_api_deployments" + assert msg["args"] == ["org", "wf", "exec"] + assert msg["kwargs"] == {"transport": "pg_queue"} + assert msg["fairness"]["workload_type"] == "api" + + def test_uuid_args_kwargs_are_json_coerced(self): + """PgQueueMessage.message is a plain JSONField → UUIDs in args/kwargs must + be coerced to str (the worker consumer receives string ids).""" + wf = uuid.UUID("ebed2834-c9fb-4b6c-8df3-9dd841f616bb") + with patch(_MODEL) as model: + model.objects.create.return_value = MagicMock(msg_id=1) + producer.enqueue_task( + task_name="async_execute_bin", + queue="celery", + args=[wf], + kwargs={"pipeline_id": wf}, + ) + msg = model.objects.create.call_args.kwargs["message"] + assert msg["args"] == [str(wf)] + assert msg["kwargs"] == {"pipeline_id": str(wf)} + assert all(isinstance(a, str) for a in msg["args"]) + + def test_none_queue_defaults_to_general(self): + with patch(_MODEL) as model: + model.objects.create.return_value = MagicMock(msg_id=1) + producer.enqueue_task(task_name="async_execute_bin", queue=None) + kw = model.objects.create.call_args.kwargs + assert kw["queue_name"] == producer.DEFAULT_GENERAL_QUEUE == "celery" + assert kw["message"]["queue"] == "celery" + + def test_empty_args_kwargs_and_no_fairness(self): + with patch(_MODEL) as model: + model.objects.create.return_value = MagicMock(msg_id=1) + producer.enqueue_task(task_name="t", queue="celery") + msg = model.objects.create.call_args.kwargs["message"] + assert msg["args"] == [] + assert msg["kwargs"] == {} + assert msg["fairness"] is None + + @pytest.mark.parametrize("priority", [1, 5, 10]) + def test_priority_boundary_values_accepted(self, priority): + with patch(_MODEL) as model: + model.objects.create.return_value = MagicMock(msg_id=1) + producer.enqueue_task(task_name="t", queue="celery", priority=priority) + assert model.objects.create.call_args.kwargs["priority"] == priority + + @pytest.mark.parametrize("priority", [0, 11, -1]) + def test_priority_out_of_range_raises(self, priority): + with pytest.raises(ValueError): + producer.enqueue_task(task_name="t", queue="celery", priority=priority) + + def test_default_priority_when_omitted(self): + with patch(_MODEL) as model: + model.objects.create.return_value = MagicMock(msg_id=1) + producer.enqueue_task(task_name="t", queue="celery") + assert model.objects.create.call_args.kwargs["priority"] == 5 # FAIRNESS_DEFAULT + + def test_json_safe_coerces_datetime(self): + dt = datetime.datetime(2026, 6, 18, 12, 0, 0) + with patch(_MODEL) as model: + model.objects.create.return_value = MagicMock(msg_id=1) + producer.enqueue_task( + task_name="t", queue="celery", kwargs={"when": dt} + ) + when = model.objects.create.call_args.kwargs["message"]["kwargs"]["when"] + assert isinstance(when, str) and "2026-06-18" in when + + def test_enqueue_failure_logs_and_propagates(self): + with patch(_MODEL) as model: + model.objects.create.side_effect = RuntimeError("db down") + with pytest.raises(RuntimeError): + producer.enqueue_task(task_name="t", queue="celery") diff --git a/backend/workflow_manager/workflow_v2/tests/test_dispatch_orchestrator.py b/backend/workflow_manager/workflow_v2/tests/test_dispatch_orchestrator.py new file mode 100644 index 0000000000..6b0a03553b --- /dev/null +++ b/backend/workflow_manager/workflow_v2/tests/test_dispatch_orchestrator.py @@ -0,0 +1,62 @@ +"""Tests for WorkflowHelper._dispatch_orchestrator_task — the PG-vs-Celery +orchestrator dispatch fork (9e 2d). + +DB-free: pg_enqueue_task and celery_app are mocked, so these pin the routing +decision (PG enqueue vs Celery send), the bare-msg_id task-id contract, the +WorkloadType value, and the two org sentinels. +""" + +from unittest.mock import MagicMock, patch + +from utils.constants import CeleryQueue +from workflow_manager.workflow_v2.workflow_helper import WorkflowHelper + +_PG = "workflow_manager.workflow_v2.workflow_helper.pg_enqueue_task" +_CELERY = "workflow_manager.workflow_v2.workflow_helper.celery_app" + + +class TestDispatchOrchestratorTask: + def test_pg_transport_enqueues_to_pg_not_celery(self): + with patch(_PG, return_value=4242) as pg, patch(_CELERY) as celery: + task_id = WorkflowHelper._dispatch_orchestrator_task( + transport="pg_queue", + queue=CeleryQueue.CELERY_API_DEPLOYMENTS, + args=["a"], + kwargs={"transport": "pg_queue"}, + org_schema="org1", + ) + # bare msg_id, no "pg:" prefix — matches the worker PgDispatchHandle.id + assert task_id == "4242" + celery.send_task.assert_not_called() + kw = pg.call_args.kwargs + assert kw["task_name"] == "async_execute_bin" + assert kw["queue"] == CeleryQueue.CELERY_API_DEPLOYMENTS + assert kw["org_id"] == "org1" + assert kw["fairness"]["workload_type"] == "api" + + def test_pg_general_queue_uses_non_api_workload(self): + with patch(_PG, return_value=7) as pg, patch(_CELERY): + WorkflowHelper._dispatch_orchestrator_task( + transport="pg_queue", queue=None, args=[], kwargs={}, org_schema="org1" + ) + assert pg.call_args.kwargs["fairness"]["workload_type"] == "non_api" + + def test_empty_org_uses_two_sentinels(self): + """Row org_id column is NOT NULL → ""; fairness org_id is str|None → None.""" + with patch(_PG, return_value=1) as pg, patch(_CELERY): + WorkflowHelper._dispatch_orchestrator_task( + transport="pg_queue", queue=None, args=[], kwargs={}, org_schema="" + ) + kw = pg.call_args.kwargs + assert kw["org_id"] == "" + assert kw["fairness"]["org_id"] is None + + def test_celery_transport_uses_send_task_not_pg(self): + with patch(_PG) as pg, patch(_CELERY) as celery: + celery.send_task.return_value = MagicMock(id="celery-task-1") + task_id = WorkflowHelper._dispatch_orchestrator_task( + transport="celery", queue=None, args=[], kwargs={}, org_schema="org1" + ) + assert task_id == "celery-task-1" + pg.assert_not_called() + celery.send_task.assert_called_once() diff --git a/backend/workflow_manager/workflow_v2/workflow_helper.py b/backend/workflow_manager/workflow_v2/workflow_helper.py index 416373c9c1..da868b76f2 100644 --- a/backend/workflow_manager/workflow_v2/workflow_helper.py +++ b/backend/workflow_manager/workflow_v2/workflow_helper.py @@ -8,11 +8,12 @@ from account_v2.constants import Common from api_v2.models import APIDeployment from celery import chord, current_task -from celery import exceptions as celery_exceptions from celery.result import AsyncResult from configuration.enums import ConfigKey from configuration.models import Configuration from django.db import IntegrityError +from pg_queue.producer import DEFAULT_PRIORITY as PG_DEFAULT_PRIORITY +from pg_queue.producer import enqueue_task as pg_enqueue_task from pipeline_v2.models import Pipeline from plugins import get_plugin from plugins.workflow_manager.workflow_v2.utils import WorkflowUtil @@ -26,6 +27,7 @@ from utils.user_context import UserContext from backend.celery_service import app as celery_app +from unstract.core.data_models import WorkloadType, is_pg_transport from unstract.workflow_execution.enums import LogStage from workflow_manager.endpoint_v2.destination import DestinationConnector from workflow_manager.endpoint_v2.dto import FileHash @@ -470,6 +472,51 @@ def _get_execution_status( ) return execution_cache.status + @staticmethod + def _dispatch_orchestrator_task( + *, + transport: str, + queue: str | None, + args: list[Any], + kwargs: dict[str, Any], + org_schema: str, + ) -> str | None: + """Dispatch ``async_execute_bin`` on the resolved transport; return its id. + + ``pg_queue`` → enqueue to ``pg_queue_message`` (a PG consumer runs it); + ``celery`` → ``celery_app.send_task``. The returned id is the **bare** + ``str(msg_id)`` (PG) or the Celery task id — one format across entry + paths, matching the worker PG dispatch (``PgDispatchHandle.id``). + """ + if is_pg_transport(transport): + is_api_execution = queue == CeleryQueue.CELERY_API_DEPLOYMENTS + msg_id = pg_enqueue_task( + task_name="async_execute_bin", + # None → "celery" (general); else celery_api_deployments. + queue=queue, + args=args, + kwargs=kwargs, + # Two sentinels, deliberately: the row's org_id column is NOT NULL + # (wants ""), while the fairness payload's org_id is str | None (a + # missing org means "no segment", not the literal "None"). + org_id=org_schema or "", + priority=PG_DEFAULT_PRIORITY, + fairness={ + "org_id": org_schema or None, + "workload_type": ( + WorkloadType.API.value + if is_api_execution + else WorkloadType.NON_API.value + ), + "pipeline_priority": PG_DEFAULT_PRIORITY, + }, + ) + return str(msg_id) + async_execution = celery_app.send_task( + "async_execute_bin", args=args, kwargs=kwargs, queue=queue + ) + return async_execution.id + @classmethod def execute_workflow_async( cls, @@ -502,6 +549,9 @@ def execute_workflow_async( Returns: ExecutionResponse: Existing status of execution """ + # Defined before the try so the handler can tell a pre-dispatch failure + # (mark ERROR) from a post-dispatch one (orchestrator already running). + dispatched = False try: file_hash_in_str = { key: value.to_json() for key, value in hash_values_of_files.items() @@ -526,48 +576,58 @@ def execute_workflow_async( workflow_id=workflow_id, pipeline_id=pipeline_id, ) - async_execution: AsyncResult = celery_app.send_task( - "async_execute_bin", - args=[ - org_schema, # schema_name - workflow_id, # workflow_id - execution_id, # execution_id - file_hash_in_str, # hash_values_of_files - ], - kwargs={ - "scheduled": False, - "execution_mode": None, - "pipeline_id": pipeline_id, - "log_events_id": log_events_id, - "use_file_history": use_file_history, - "llm_profile_id": llm_profile_id, - "hitl_queue_name": hitl_queue_name, - "hitl_packet_id": hitl_packet_id, - "custom_data": custom_data, - "transport": transport, - }, + dispatch_args = [ + org_schema, # schema_name + workflow_id, # workflow_id + execution_id, # execution_id + file_hash_in_str, # hash_values_of_files + ] + dispatch_kwargs = { + "scheduled": False, + "execution_mode": None, + "pipeline_id": pipeline_id, + "log_events_id": log_events_id, + "use_file_history": use_file_history, + "llm_profile_id": llm_profile_id, + "hitl_queue_name": hitl_queue_name, + "hitl_packet_id": hitl_packet_id, + "custom_data": custom_data, + "transport": transport, + } + # Orchestrator transport (9e PR A / 2d): dispatch async_execute_bin on + # the resolved transport (PG enqueue vs Celery). Extracted to a helper + # so this method stays simple and the fork is unit-testable. + task_id = cls._dispatch_orchestrator_task( + transport=transport, queue=queue, + args=dispatch_args, + kwargs=dispatch_kwargs, + org_schema=org_schema or "", ) - logger.info( - f"[{org_schema}] Job '{async_execution}' has been enqueued for " - f"execution_id '{execution_id}', '{len(hash_values_of_files)}' files" - ) + # Past this point the orchestrator is on its transport: a failure in + # the bookkeeping below must NOT flip the (now-running) row to ERROR. + dispatched = True + workflow_execution: WorkflowExecution = WorkflowExecution.objects.get( id=execution_id ) - if not async_execution.id: + if not task_id: + # PG always yields a truthy msg_id, so an empty id is Celery-only; + # keep the transport in the message so operators can tell. logger.warning( - f"[{org_schema}] Celery returned empty task_id for execution_id '{execution_id}'. " + f"[{org_schema}] Empty task_id (transport={transport}) for " + f"execution_id '{execution_id}'." ) # Continue without setting task_id - execution can still complete else: # Use existing method to handle task_id setting with validation WorkflowExecutionServiceHelper.update_execution_task( - execution_id=execution_id, task_id=async_execution.id + execution_id=execution_id, task_id=task_id ) logger.info( - f"[{org_schema}] Job '{async_execution.id}' has been enqueued for " - f"execution_id '{execution_id}', '{len(hash_values_of_files)}' files" + f"[{org_schema}] Job '{task_id}' enqueued (transport={transport}) " + f"for execution_id '{execution_id}', " + f"'{len(hash_values_of_files)}' files" ) execution_status = workflow_execution.status @@ -581,36 +641,39 @@ def execute_workflow_async( ) if ExecutionStatus.is_completed(execution_status): # Fetch the object agian to get the latest status. - workflow_execution: WorkflowExecution = WorkflowExecution.objects.get( - id=execution_id - ) + workflow_execution = WorkflowExecution.objects.get(id=execution_id) task_result = ResultCacheUtils.get_api_results( workflow_id=workflow_id, execution_id=execution_id ) cls._set_result_acknowledge(workflow_execution) else: task_result = None - execution_response = ExecutionResponse( + return ExecutionResponse( workflow_id, execution_id, execution_status, result=task_result, ) - return execution_response - except celery_exceptions.TimeoutError: - return ExecutionResponse( - workflow_id, - execution_id, - async_execution.status, - message=WorkflowMessages.CELERY_TIMEOUT_MESSAGE, - ) except Exception as error: + if dispatched: + # The orchestrator is already enqueued/running on its transport, so + # a post-dispatch bookkeeping failure (status read / poll / result + # fetch) must not mark the execution ERROR — the orchestrator owns + # the status now. (The old Celery-only TimeoutError handler is + # dropped: the wait loop is a manual poll, not AsyncResult.get, so + # it never raised; this generic handler covers any stray case.) + logger.exception( + f"[{org_schema}] Post-dispatch bookkeeping failed for execution " + f"'{execution_id}' (orchestrator already dispatched on " + f"{transport}); not marking ERROR" + ) + return ExecutionResponse( + workflow_id, execution_id, ExecutionStatus.EXECUTING.value + ) WorkflowExecutionServiceHelper.update_execution_err(execution_id, str(error)) - logger.error( + logger.exception( f"Error while enqueuing async job for WF '{workflow_id}', " - f"execution '{execution_id}': {str(error)}", - exc_info=True, - stack_info=True, + f"execution '{execution_id}'" ) return ExecutionResponse( workflow_id, diff --git a/unstract/core/src/unstract/core/data_models.py b/unstract/core/src/unstract/core/data_models.py index 20e3d0c3aa..94e6148305 100644 --- a/unstract/core/src/unstract/core/data_models.py +++ b/unstract/core/src/unstract/core/data_models.py @@ -9,8 +9,8 @@ import uuid from dataclasses import asdict, dataclass, field from datetime import UTC, datetime -from enum import Enum -from typing import Any +from enum import Enum, StrEnum +from typing import Any, Literal, TypedDict logger = logging.getLogger(__name__) @@ -260,6 +260,70 @@ def is_pg_transport(transport: str | None) -> bool: return transport == WorkflowTransport.PG_QUEUE.value +class WorkloadType(StrEnum): + """Workflow-execution workload class (fairness L2). Binary api-vs-not. + + Shared single source of truth: the workers' ``queue_backend.fairness`` + re-exports this, and the backend producer references its values, so neither + side hand-builds the wire strings. A ``str`` Enum serialises to its value + under ``json.dumps`` / the queue payload. + """ + + API = "api" + NON_API = "non_api" + + +# Fairness L3 priority bounds (1..10, higher = claimed sooner). Single source of +# truth shared by the backend producer and the workers' fairness/queue client — +# the DB CheckConstraint on pg_queue_message.priority is the writer-proof backstop. +FAIRNESS_MIN_PRIORITY = 1 +FAIRNESS_MAX_PRIORITY = 10 +FAIRNESS_DEFAULT_PRIORITY = 5 + + +class FairnessPayload(TypedDict): + """Serialised fairness key — the wire shape of a ``FairnessKey.to_dict()``. + + The PG-queue **backend↔worker contract**: the backend (producer) and the + workers' ``queue_backend.fairness`` (consumer) both reference this exact + sub-shape so they agree on the keys instead of a loose ``dict[str, Any]``. + Lives in ``unstract.core`` because backend and workers are separate + codebases that can't import each other — this is their single source of + truth. ``workload_type`` is a :class:`WorkloadType` value; + ``pipeline_priority`` is the 1..10 fairness L3 priority. + """ + + org_id: str | None + workload_type: Literal["api", "non_api"] + pipeline_priority: int + + +class TaskPayload(TypedDict): + """The ``message`` JSONB of a task row in ``pg_queue_message``. + + The PG-queue **producer↔consumer contract**: whoever enqueues a task (the + workers' ``dispatch()`` PG path, or the backend orchestrator producer) + serialises this; the consumer poll loop decodes it and runs the task. Keep + both sides reading the same keys. Shared in ``unstract.core`` so the backend + producer and the worker consumer agree on one definition. + + ``fairness`` is a :class:`FairnessPayload` (serialised fairness key) or + ``None`` — the consumer rebuilds the ``x-fairness-key`` header from it, + mirroring the Celery dispatch path. + + ``queue`` is diagnostic only: the consumer routes by the ``queue_name`` + *column* of the row, not by this field. Producers record the logical queue + here for debugging (the backend producer always sets it; the workers' + ``to_payload`` may leave it ``None``). + """ + + task_name: str + args: list[Any] + kwargs: dict[str, Any] + queue: str | None + fairness: FairnessPayload | None + + class FileListingResult: """Result of listing files from a source.""" diff --git a/workers/queue_backend/fairness.py b/workers/queue_backend/fairness.py index 160b393e7b..29e5afdfa5 100644 --- a/workers/queue_backend/fairness.py +++ b/workers/queue_backend/fairness.py @@ -8,33 +8,33 @@ from __future__ import annotations from dataclasses import dataclass -from enum import StrEnum -from typing import Final, TypedDict - - -class FairnessPayload(TypedDict): - """Serialised :class:`FairnessKey` (``to_dict()`` / wire shape). - - Shared so producers and the PG ``TaskPayload`` wire contract agree on - the exact sub-shape instead of a loose ``dict[str, Any]``. - """ - - org_id: str | None - workload_type: str - pipeline_priority: int - - -class WorkloadType(StrEnum): - """Workflow execution type. Labs L2 check is binary api-vs-not.""" - - API = "api" - NON_API = "non_api" - - -# pipeline_priority bounds per labs schema (1..10, higher = sooner). -MIN_PRIORITY: Final[int] = 1 -MAX_PRIORITY: Final[int] = 10 -DEFAULT_PRIORITY: Final[int] = 5 +from typing import Final + +# The wire shape, the WorkloadType enum, and the priority bounds now live in +# unstract.core (shared backend↔worker single source of truth); re-exported here +# so existing ``from ..fairness import FairnessPayload/WorkloadType/...`` imports +# keep working and the backend producer references the same definitions. +from unstract.core.data_models import ( + FAIRNESS_DEFAULT_PRIORITY as DEFAULT_PRIORITY, +) +from unstract.core.data_models import ( + FAIRNESS_MAX_PRIORITY as MAX_PRIORITY, +) +from unstract.core.data_models import ( + FAIRNESS_MIN_PRIORITY as MIN_PRIORITY, +) +from unstract.core.data_models import ( + FairnessPayload, + WorkloadType, +) + +__all__ = [ + "DEFAULT_PRIORITY", + "MAX_PRIORITY", + "MIN_PRIORITY", + "FairnessPayload", + "WorkloadType", +] # Header (not kwarg) so task-body signatures without **kwargs aren't broken. FAIRNESS_HEADER_NAME: Final[str] = "x-fairness-key" diff --git a/workers/queue_backend/pg_queue/task_payload.py b/workers/queue_backend/pg_queue/task_payload.py index 3a7cc5e7fd..919914bfae 100644 --- a/workers/queue_backend/pg_queue/task_payload.py +++ b/workers/queue_backend/pg_queue/task_payload.py @@ -11,28 +11,18 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any -from ..fairness import FairnessPayload +# TaskPayload now lives in unstract.core (shared backend↔worker wire contract); +# re-exported here so existing ``from .task_payload import TaskPayload`` imports +# keep working. The ``to_payload`` builder stays worker-side (it depends on the +# worker-only ``FairnessKey``). +from unstract.core.data_models import TaskPayload if TYPE_CHECKING: from ..fairness import FairnessKey - -class TaskPayload(TypedDict): - """Everything the 9c consumer needs to run a PG-routed task. - - ``fairness`` is the serialised :class:`FairnessKey` - (:class:`~queue_backend.fairness.FairnessPayload`) or ``None`` — the - consumer rebuilds the ``x-fairness-key`` header from it, mirroring the - Celery dispatch path. - """ - - task_name: str - args: list[Any] - kwargs: dict[str, Any] - queue: str | None - fairness: FairnessPayload | None +__all__ = ["TaskPayload", "to_payload"] def to_payload( diff --git a/workers/scheduler/tasks.py b/workers/scheduler/tasks.py index 7518a4b930..789f7cac90 100644 --- a/workers/scheduler/tasks.py +++ b/workers/scheduler/tasks.py @@ -7,7 +7,7 @@ import traceback from typing import Any -from queue_backend import FairnessKey, dispatch, worker_task +from queue_backend import FairnessKey, QueueBackend, dispatch, worker_task from queue_backend.fairness import WorkloadType from shared.enums.status_enums import PipelineStatus from shared.enums.worker_enums import QueueName @@ -27,6 +27,7 @@ DEFAULT_WORKFLOW_TRANSPORT, NotificationPayload, NotificationSource, + is_pg_transport, normalize_transport, ) @@ -182,6 +183,10 @@ def _execute_scheduled_workflow( "transport": transport, }, queue=QueueName.GENERAL, + # Orchestrator transport (9e PR A / 2d): route async_execute_bin + # onto PG for a pg_queue execution (carried-marker wins over the + # allow-list); None keeps the legacy Celery dispatch. + backend=QueueBackend.PG if is_pg_transport(transport) else None, fairness=FairnessKey( org_id=context.organization_id, workload_type=WorkloadType.NON_API, diff --git a/workers/tests/test_dispatch_sites_characterisation.py b/workers/tests/test_dispatch_sites_characterisation.py index 48ffe33811..f34d3d838e 100644 --- a/workers/tests/test_dispatch_sites_characterisation.py +++ b/workers/tests/test_dispatch_sites_characterisation.py @@ -256,6 +256,32 @@ def test_dispatch_carries_backend_resolved_transport(self): assert mock_dispatch.call_args.kwargs["kwargs"]["transport"] == "pg_queue" + def test_pg_transport_routes_dispatch_to_pg_backend(self): + """The one line that actually routes the scheduled orchestrator onto PG: + transport=="pg_queue" → dispatch(backend=QueueBackend.PG) (identity, not + the allow-list).""" + from queue_backend import QueueBackend + from scheduler.tasks import _execute_scheduled_workflow + + api = MagicMock() + api.create_workflow_execution.return_value = { + "execution_id": "exec-123", + "transport": "pg_queue", + } + with patch("scheduler.tasks.dispatch") as mock_dispatch: + _execute_scheduled_workflow(api, self._make_context()) + + assert mock_dispatch.call_args.kwargs["backend"] is QueueBackend.PG + + def test_celery_transport_leaves_backend_none(self): + """transport=="celery" → backend=None (legacy Celery dispatch unchanged).""" + from scheduler.tasks import _execute_scheduled_workflow + + with patch("scheduler.tasks.dispatch") as mock_dispatch: + _execute_scheduled_workflow(self._make_api_client(), self._make_context()) + + assert mock_dispatch.call_args.kwargs["backend"] is None + def test_no_dispatch_when_execution_creation_fails(self): """If api_client.create_workflow_execution returns no execution_id, the function bails out and never calls send_task.""" From 5f4cdf089104d6eb9b9fab14992469e79602b9c2 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:34:03 +0530 Subject: [PATCH 22/65] =?UTF-8?q?UN-3566=20[FEAT]=20PG=20Queue=209f=20?= =?UTF-8?q?=E2=80=94=20multi-queue=20consumer=20+=20named=20run-worker.sh?= =?UTF-8?q?=20PG=20roles=20(#2073)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3566 [FEAT] PG Queue 9f — multi-queue consumer + named run-worker.sh PG roles Make PG-queue workers first-class in run-worker.sh, runnable individually or as a set, like the Celery workers — removing the per-process-env footgun. - Multi-queue consumer: PgQueueConsumer takes queue_names: list[str] and polls them round-robin (per-queue read preserves the dequeue index's top-N; no queue starves another). WORKER_PG_QUEUE_CONSUMER_QUEUE is comma-parsed; a single value stays a one-element list (back-compat with the existing leaf consumer). - run-worker.sh named roles (PG_CONSUMER_ROLES), each a registry-bound consumer with its source worker-type + queue list baked in (no manual env): pg-orchestrator-api / pg-orchestrator-general (async_execute_bin has distinct impls per registry → split), pg-fileproc / pg-callback (multi-queue, one process drains ETL+API). The role name rides in argv (python -m pg_queue_consumer ) so pgrep (-s/-k/-r) tells co-running roles apart. - `./run-worker.sh -d pg` launches the 4 pipeline roles + reaper (mirrors `all`); each role also runs/kills/restarts/tails individually. resolve_log_file + the -L pg set + status/kill enumerate the roles. Tests: round-robin aggregation, empty-queue-no-starve, empty-list reject, comma-parse (incl single-value back-compat); existing consumer tests updated to the list arg. Dev-tested: `./run-worker.sh -d pg` → status shows 4 roles + reaper RUNNING; an API execution drains orchestrator-api → fileproc (draining both file_processing and api_file_processing) → callback → COMPLETED; `-r pg-fileproc` restarts only that role, leaving pg-callback untouched. Co-Authored-By: Claude Opus 4.8 * UN-3566 run-worker.sh: accept multiple worker types + tighten generic consumer pgrep - Multiple positional worker types: `./run-worker.sh -d all pg` starts the Celery set and the PG set in one shot (loops WORKER_TYPES). Warns if >1 type is given without -d, since a non-detached set `wait`s and would block the rest. - Tighten the generic pg_queue_consumer pgrep to end-anchored so --status/-k/-r for the generic consumer no longer also match (and aggregate/kill) the named role consumers, which run as `python -m pg_queue_consumer ` and have their own role-anchored pattern. Co-Authored-By: Claude Opus 4.8 * UN-3566 address sonar: hoist PG role-name literals into constants Define PG_ROLE_ORCH_API / _ORCH_GENERAL / _FILEPROC / _CALLBACK once and reference them in PG_CONSUMER_ROLES, PG_QUEUE_MEMBERS and WORKERS, instead of repeating each role-name string literal 4×. Co-Authored-By: Claude Opus 4.8 * UN-3566 run-worker.sh: add 'celery' run alias for the Celery set (symmetric with 'pg') `./run-worker.sh -d celery` now runs the Celery set (== `all`, excludes the PG workers), so celery-only / pg-only / both read symmetrically: -d celery (Celery only) -d pg (PG only) -d celery pg | -d all pg (both) 'celery' maps to "all" in WORKERS → dispatches to run_all_workers, is skipped by list_core_worker_dirs (no phantom dir), and falls through to the restart-all branch under -r. The pre-existing `-L celery` log-tail alias is unaffected. Co-Authored-By: Claude Opus 4.8 * UN-3566 address review: -r pg kills roles, per-queue isolation, dedup, stale comments Critical/High + cleanup from the #2073 review round: - [Critical] `-r pg` (restart set) now kills the SAME members run_pg_queue_set launches (the 4 named roles + reaper), not just the generic consumer — was leaving the roles running, so a relaunch double-polled Postgres. - [High] poll_once isolates each queue: a read/handle failure on one queue is logged and skipped so the others still run AND the work already done this cycle still counts (no false empty-queue backoff after a partial failure). - [High] fixed the stale CELERY_SET comment that claimed "no 'celery' run alias". - [Medium-High] run_pg_queue_set start-failure teardown aggregates kill_one_worker returns and surfaces a survivor (mirrors the restart path). - [Medium] de-dup + copy queue_names (list(dict.fromkeys(...))) — a duplicate would double-read a queue; the copy stops a caller mutation bypassing validation. - [Medium] _parse_queue_list warns when it drops empty entries (config typo). - [Med/Low] comment fixes: only fileproc/callback are multi-queue; "4 roles + reaper" not "consumer + reaper"; "read once per cycle in list order" not round-robin. Cleanups: drop redundant export; cwd predicate uses PG_QUEUE_MEMBERS; spell out api_file_processing_callback in help. Tests: +one-queue-failing-isolation, +batch_size qty per queue, +duplicate dedup. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/queue_backend/pg_queue/consumer.py | 73 ++++-- workers/run-worker.sh | 254 +++++++++++++++------ workers/tests/test_pg_queue_consumer.py | 116 +++++++--- 3 files changed, 330 insertions(+), 113 deletions(-) diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index 62771921c4..cdf7a20836 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -70,7 +70,7 @@ class PgQueueConsumer: def __init__( self, - queue_name: str, + queue_names: list[str], *, client: PgQueueClient | None = None, app: Celery | None = None, @@ -97,7 +97,17 @@ def __init__( raise ValueError( f"backoff_max ({backoff_max}) must be >= poll_interval ({poll_interval})" ) - self.queue_name = queue_name + if not queue_names: + raise ValueError("queue_names must be a non-empty list") + # One process can drain several queues (9f) — e.g. a file_processing + # consumer drains both file_processing and api_file_processing. Each is + # read once per cycle in list order (poll_once): this prevents starvation + # (every queue gets a read each cycle) but does NOT equalize throughput — + # an always-full queue_names[0] claims its full batch and runs it before + # later queues. Copy + de-dup (order-preserving): a duplicate would + # double-read a queue per cycle, and storing the caller's list by + # reference would let a later mutation bypass the non-empty validation. + self.queue_names = list(dict.fromkeys(queue_names)) self._client = client if client is not None else PgQueueClient() self._app = app if app is not None else current_app self.batch_size = batch_size @@ -114,14 +124,31 @@ def __init__( self._last_poll_monotonic = time.monotonic() def poll_once(self) -> int: - """Claim + process one batch; returns the number of messages claimed.""" + """Claim + process one batch per queue (read once each, in list order); + returns the total number of messages claimed across all queues this cycle. + + Each queue is isolated: a read/handle failure on one queue is logged and + skipped so the others still get their turn, and the work already done this + cycle still counts (so run() doesn't take the empty-queue backoff path + after a partial failure). + """ self._last_poll_monotonic = time.monotonic() - messages = self._client.read( - self.queue_name, vt_seconds=self.vt_seconds, qty=self.batch_size - ) - for message in messages: - self._handle(message) - return len(messages) + total = 0 + for queue_name in self.queue_names: + try: + messages = self._client.read( + queue_name, vt_seconds=self.vt_seconds, qty=self.batch_size + ) + for message in messages: + self._handle(message) + total += len(messages) + except Exception: + logger.exception( + "PG-queue consumer: poll failed for queue %r; " + "continuing with the other queues", + queue_name, + ) + return total def _handle(self, message: QueueMessage) -> None: payload = message.message @@ -254,9 +281,9 @@ def run(self, *, install_signals: bool = True, require_tasks: bool = True) -> No name for name in self._app.tasks if not name.startswith("celery.") ) logger.info( - "PG-queue consumer started (queue=%r, batch=%s, vt=%ss) — " + "PG-queue consumer started (queues=%r, batch=%s, vt=%ss) — " "%d application task(s) registered: %s", - self.queue_name, + self.queue_names, self.batch_size, self.vt_seconds, len(app_tasks), @@ -278,7 +305,7 @@ def run(self, *, install_signals: bool = True, require_tasks: bool = True) -> No else: time.sleep(backoff) backoff = min(backoff * 2, self.backoff_max) - logger.info("PG-queue consumer stopped (queue=%r)", self.queue_name) + logger.info("PG-queue consumer stopped (queues=%r)", self.queue_names) def stop(self, *_: object) -> None: """Request a graceful stop after the current batch.""" @@ -296,6 +323,26 @@ def _install_signal_handlers(self) -> None: ) +def _parse_queue_list(raw: str) -> list[str]: + """Comma-separated queue list (9f). A single value stays a one-element list, + so the pre-9f single-queue config remains valid. Empty entries (a doubled or + trailing comma — almost always a config typo) are dropped with a warning so a + malformed list is diagnosable from the logs, not just by eyeballing. + """ + parts = [q.strip() for q in raw.split(",")] + queues = [q for q in parts if q] + dropped = len(parts) - len(queues) + if dropped: + logger.warning( + "PG-queue consumer: dropped %d empty queue name(s) from " + "WORKER_PG_QUEUE_CONSUMER_QUEUE=%r → %r", + dropped, + raw, + queues, + ) + return queues + + def main() -> None: logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) @@ -315,7 +362,7 @@ def _env(suffix: str, default: _T, cast: Callable[[str], _T]) -> _T: raise ValueError(f"Invalid {var}={raw!r}: {exc}") from exc consumer = PgQueueConsumer( - queue_name=_env("QUEUE", _DEFAULT_QUEUE, str), + queue_names=_env("QUEUE", [_DEFAULT_QUEUE], _parse_queue_list), batch_size=_env("BATCH", _DEFAULT_BATCH, int), vt_seconds=_env("VT_SECONDS", _DEFAULT_VT_SECONDS, int), poll_interval=_env("POLL_INTERVAL", _DEFAULT_POLL_INTERVAL, float), diff --git a/workers/run-worker.sh b/workers/run-worker.sh index 68bb23aa11..fee8131aab 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -36,15 +36,44 @@ readonly PG_QUEUE_SET="pg-queue" # pg-queue` complement correct by construction if a third member is ever added # (callers test membership via ${PG_QUEUE_MEMBERS[$dir]:-} instead of # hand-rolling the consumer/reaper pair). +# 9f: named PG-queue consumer ROLES. Each is a multi-queue consumer (one process +# draining several queues, mirroring the Celery file_processing/callback workers +# that each cover ETL+API) with its source worker-type + queue list baked in — so +# `./run-worker.sh -d pg-fileproc` needs no per-process env. Format: +# ";". The role name is passed as argv +# to `python -m pg_queue_consumer ` so pgrep (--status/-k/-r) tells the +# co-running roles apart (they are otherwise identical processes). +# The four coupled-pipeline stages, each a registry-bound consumer (mirroring the +# Celery api-deployment / general / file_processing / callback workers). The two +# orchestrator roles are split because async_execute_bin has DISTINCT impls per +# registry (api-deployment handles it as an API deployment; general routes by +# workflow type), so one consumer can't serve both — they bind different queues. +# fileproc/callback are multi-queue (one process drains both ETL + API queues). +# Role-name constants — the literal lives in one place and is referenced across +# PG_CONSUMER_ROLES / PG_QUEUE_MEMBERS / WORKERS. +readonly PG_ROLE_ORCH_API="pg-orchestrator-api" +readonly PG_ROLE_ORCH_GENERAL="pg-orchestrator-general" +readonly PG_ROLE_FILEPROC="pg-fileproc" +readonly PG_ROLE_CALLBACK="pg-callback" +declare -rA PG_CONSUMER_ROLES=( + ["$PG_ROLE_ORCH_API"]="api_deployment;celery_api_deployments" + ["$PG_ROLE_ORCH_GENERAL"]="general;celery" + ["$PG_ROLE_FILEPROC"]="file_processing;file_processing,api_file_processing" + ["$PG_ROLE_CALLBACK"]="callback;file_processing_callback,api_file_processing_callback" +) declare -rA PG_QUEUE_MEMBERS=( ["$PG_QUEUE_CONSUMER_TYPE"]=1 ["$PG_QUEUE_REAPER_TYPE"]=1 + ["$PG_ROLE_ORCH_API"]=1 + ["$PG_ROLE_ORCH_GENERAL"]=1 + ["$PG_ROLE_FILEPROC"]=1 + ["$PG_ROLE_CALLBACK"]=1 ) -# Log-tail alias for the Celery transport: every worker EXCEPT the PG-queue -# members — the *complement* of the 'pg-queue' tail alias, so the two -# transports' logs can be tailed separately (-L celery vs -L pg-queue). -# Tail-only — there is no 'celery' run alias; this set is started via 'all', -# which by design omits the opt-in PG-queue workers. +# The Celery transport set: every worker EXCEPT the PG-queue members — the +# *complement* of the 'pg-queue' set, so the two transports' logs can be tailed +# separately (-L celery vs -L pg-queue). Since 9f, 'celery' is BOTH a -L tail +# alias AND a run alias (WORKERS maps it to "all" → run_all_workers), symmetric +# with 'pg'; 'all' remains its synonym. Either way it omits the opt-in PG workers. readonly CELERY_SET="celery" # Available workers @@ -70,6 +99,12 @@ declare -A WORKERS=( ["pg-queue-consumer"]="$PG_QUEUE_CONSUMER_TYPE" ["$PG_QUEUE_CONSUMER_TYPE"]="$PG_QUEUE_CONSUMER_TYPE" ["pg-consumer"]="$PG_QUEUE_CONSUMER_TYPE" + # PG Queue named roles (9f) — coupled-pipeline consumers, queues + source + # worker-type baked in. Run individually like any worker, or via the 'pg' set. + ["$PG_ROLE_ORCH_API"]="$PG_ROLE_ORCH_API" + ["$PG_ROLE_ORCH_GENERAL"]="$PG_ROLE_ORCH_GENERAL" + ["$PG_ROLE_FILEPROC"]="$PG_ROLE_FILEPROC" + ["$PG_ROLE_CALLBACK"]="$PG_ROLE_CALLBACK" # PG Queue reaper — leader-elected recovery loop (barrier-orphan sweep) ["reaper"]="$PG_QUEUE_REAPER_TYPE" ["pg-queue-reaper"]="$PG_QUEUE_REAPER_TYPE" @@ -78,6 +113,10 @@ declare -A WORKERS=( ["$PG_QUEUE_SET"]="$PG_QUEUE_SET" ["pg"]="$PG_QUEUE_SET" ["all"]="all" + # 'celery' is a run alias for the Celery set (== 'all', which excludes the PG + # workers) — symmetric with 'pg'. Maps to "all" so it dispatches to + # run_all_workers and list_core_worker_dirs skips it (not a phantom dir). + ["$CELERY_SET"]="all" ) # Pluggable workers will be auto-discovered at runtime @@ -149,14 +188,18 @@ WORKER_TYPE: scheduler, schedule Run scheduler worker (scheduled pipeline tasks) executor Run executor worker (extraction execution tasks) ide-callback Run IDE callback worker (Prompt Studio post-execution callbacks) - pg-queue-consumer Run PG-queue poll-loop consumer (opt-in; not part of 'all') + pg-queue-consumer Run a generic PG-queue poll-loop consumer (env-configured; opt-in) + pg-orchestrator-api Run the PG orchestrator consumer for API execs (celery_api_deployments) + pg-orchestrator-general Run the PG orchestrator consumer for ETL/general execs (celery) + pg-fileproc Run the PG fan-out consumer (file_processing + api_file_processing) + pg-callback Run the PG callback consumer (file_processing_callback + api_file_processing_callback) reaper, pg-queue-reaper Run PG-queue reaper (leader-elected recovery; opt-in) - pg, pg-queue Run the PG-queue set (consumer + reaper) together - all Run all workers (in separate processes, includes auto-discovered pluggable workers) + pg, pg-queue Run the whole PG-queue set (the 4 pipeline roles + reaper) together + all, celery Run the Celery worker set (all Celery workers; excludes the PG set) Note: Pluggable workers in pluggable_worker/ directory are automatically discovered and can be run by name. -Note: 'all' is the Celery worker set; 'pg-queue' is the PG-queue set. They are independent — - run both in parallel for a dual-transport (strangler-fig) setup. +Note: 'all' (alias 'celery') is the Celery worker set; 'pg' ('pg-queue') is the PG-queue set. They are + independent — pass both (e.g. `-d all pg`) for a dual-transport (strangler-fig) setup. Note: pg-queue-consumer overrides: WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE (source worker whose tasks to load, default notification), WORKER_PG_QUEUE_CONSUMER_QUEUE (queue to poll), and WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT (liveness server port, default 8090). @@ -194,6 +237,9 @@ EXAMPLES: # Run file processing worker in background $0 -d file + # Start BOTH transports in one shot (Celery set + PG set) — use -d so neither blocks + $0 -d all pg + # Run with custom environment file $0 -e production.env all @@ -383,8 +429,16 @@ get_worker_pids() { # `-worker@host` process, so they have no `-worker` token to anchor on. # Match the module invocation instead (covers both the `uv run python` parent # and the `python -m` child). Keeps --status / -k / -r working for them. - if [[ "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" || "$worker_type" == "$PG_QUEUE_REAPER_TYPE" ]]; then - pattern="-m[[:space:]]+${worker_type}([[:space:]]|\$)" + if [[ -n "${PG_CONSUMER_ROLES[$worker_type]:-}" ]]; then + # 9f roles run as `python -m pg_queue_consumer ` — anchor on the + # role argv so co-running roles (and the generic consumer) don't collide. + pattern="-m[[:space:]]+${PG_QUEUE_CONSUMER_TYPE}[[:space:]]+${worker_type}([[:space:]]|\$)" + elif [[ "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" || "$worker_type" == "$PG_QUEUE_REAPER_TYPE" ]]; then + # End-anchored so the GENERIC consumer doesn't also match the role + # consumers (which run as `... -m pg_queue_consumer `); they have + # their own role-anchored pattern above. The reaper takes no role arg, so + # the same end anchor matches it cleanly. + pattern="-m[[:space:]]+${worker_type}[[:space:]]*\$" else pattern="[^[:alnum:]_]${worker_type}-worker(@|-)" fi @@ -443,10 +497,15 @@ resolve_log_file() { local worker_dir=$1 local core_path="$WORKERS_DIR/$worker_dir/$worker_dir.log" local pluggable_path="$WORKERS_DIR/pluggable_worker/$worker_dir/$worker_dir.log" + # 9f roles run from (and log under) the pg_queue_consumer launcher dir as + # ".log", since they share that dir rather than having their own. + local role_path="$WORKERS_DIR/$PG_QUEUE_CONSUMER_TYPE/$worker_dir.log" if [[ -f "$core_path" ]]; then echo "$core_path" elif [[ -f "$pluggable_path" ]]; then echo "$pluggable_path" + elif [[ -n "${PG_CONSUMER_ROLES[$worker_dir]:-}" && -f "$role_path" ]]; then + echo "$role_path" fi } @@ -477,8 +536,9 @@ tail_logs() { exit 1 fi if [[ "$canonical" == "$PG_QUEUE_SET" ]]; then - # The set alias maps to no single dir — tail both member logs. - for d in "$PG_QUEUE_CONSUMER_TYPE" "$PG_QUEUE_REAPER_TYPE"; do + # The set alias maps to no single dir — tail every member's log + # (the named role consumers + the reaper). + for d in "${!PG_CONSUMER_ROLES[@]}" "$PG_QUEUE_REAPER_TYPE"; do local member_log member_log=$(resolve_log_file "$d") [[ -n "$member_log" ]] && log_files+=("$member_log") @@ -623,6 +683,10 @@ run_worker() { if [[ -n "${PLUGGABLE_WORKERS[$worker_type]:-}" ]]; then # Pluggable worker - use subdirectory worker_dir="$WORKERS_DIR/pluggable_worker/$worker_type" + elif [[ -n "${PG_CONSUMER_ROLES[$worker_type]:-}" ]]; then + # 9f role consumers share the pg_queue_consumer launcher dir (there is no + # per-role directory; the role only selects queues + source worker-type). + worker_dir="$WORKERS_DIR/$PG_QUEUE_CONSUMER_TYPE" else # Core worker - use root directory worker_dir="$WORKERS_DIR/$worker_type" @@ -770,17 +834,26 @@ run_worker() { # PG queue consumer is a plain Python poll-loop (polls Postgres via # SKIP LOCKED, not a Celery/RabbitMQ worker) — override the celery command # with the bootstrapping launcher and route the queue via env. - if [[ "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" ]]; then + pg_consumer_role="${PG_CONSUMER_ROLES[$worker_type]:-}" + if [[ "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" || -n "$pg_consumer_role" ]]; then + # A named 9f role bakes in its source worker-type + multi-queue list + # (";"); the generic pg-queue-consumer reads them from env + # (default source worker = notification, the first migrated leaf task). + if [[ -n "$pg_consumer_role" ]]; then + # Plain assignment — the line below is the single export point (its + # :- default/override is load-bearing for the generic consumer path). + WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE="${pg_consumer_role%%;*}" + queues="${pg_consumer_role#*;}" + fi export WORKER_PG_QUEUE_CONSUMER_QUEUE="$queues" - # The consumer registers ONE source worker's tasks (the launcher sets - # WORKER_TYPE from this before `import worker`). Default: notification — - # the worker that owns the first migrated leaf task, - # send_webhook_notification; override to drain another worker's queue. export WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE="${WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE:-notification}" - # Liveness HTTP server port (-p override wins, else the map default). - # Exported so the launcher's main() opts into the health server. - export WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT="${health_port:-${WORKER_HEALTH_PORTS[$worker_type]}}" + # Liveness HTTP server port (-p override wins, else the map default; roles + # have no map entry → empty → the launcher skips the health server). + export WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT="${health_port:-${WORKER_HEALTH_PORTS[$worker_type]:-}}" cmd_args=("uv" "run" "python" "-m" "$PG_QUEUE_CONSUMER_TYPE") + # Tag the process with the role name so pgrep (--status/-k/-r) can tell + # co-running roles apart — they are otherwise identical `python -m` procs. + [[ -n "$pg_consumer_role" ]] && cmd_args+=("$worker_type") fi # PG queue reaper — a leader-elected SQL recovery loop (no Celery, no task @@ -804,9 +877,10 @@ run_worker() { # Change to appropriate directory # For pluggable workers, stay at workers root to allow module imports # For core workers, change to worker directory - if [[ -n "${PLUGGABLE_WORKERS[$worker_type]:-}" || "$worker_type" == "$PG_QUEUE_CONSUMER_TYPE" || "$worker_type" == "$PG_QUEUE_REAPER_TYPE" ]]; then + if [[ -n "${PLUGGABLE_WORKERS[$worker_type]:-}" || -n "${PG_QUEUE_MEMBERS[$worker_type]:-}" ]]; then # Run from the workers root so `python -m pg_queue_consumer` / - # `python -m pg_queue_reaper` (and what they import) resolve. + # `python -m pg_queue_reaper` (and what they import) resolve. PG_QUEUE_MEMBERS + # is the single source of truth for "which workers are PG-queue members". cd "$WORKERS_DIR" else cd "$worker_dir" @@ -902,7 +976,7 @@ run_all_workers() { fi } -# Function to run the PG-queue worker set (consumer + reaper) together. +# Function to run the PG-queue worker set (the 4 pipeline roles + reaper) together. # A set is multiple processes, so — like 'all' — it ALWAYS runs detached # (it ignores -d/--detach; there's no foreground form). The PG-queue counterpart # to 'all' (the Celery set): run both for a dual-transport (strangler-fig) setup. @@ -915,9 +989,13 @@ run_pg_queue_set() { local concurrency=$2 local pool_type=$3 - print_status $GREEN "Starting PG-queue set (consumer + reaper)..." + # The set = the named pipeline consumer roles (9f) + the reaper. The fan-out + # and callback roles are multi-queue (ETL+API); the two orchestrator roles + # each bind a single registry-specific queue (see PG_CONSUMER_ROLES). + local members=("${!PG_CONSUMER_ROLES[@]}" "$PG_QUEUE_REAPER_TYPE") + print_status $GREEN "Starting PG-queue set (${members[*]})..." local failed=0 - for worker in "$PG_QUEUE_CONSUMER_TYPE" "$PG_QUEUE_REAPER_TYPE"; do + for worker in "${members[@]}"; do print_status $BLUE "Starting $worker in background..." # A FOREGROUND subshell: run_worker (detach=true) nohup-backgrounds the # actual worker and returns 1 on an immediate crash-on-start — so the @@ -931,11 +1009,17 @@ run_pg_queue_set() { if [[ $failed -ne 0 ]]; then print_status $RED "PG-queue set: a member failed to start — tearing down the set (see logs above)" # Don't leave a survivor: a restart-on-failure relaunch would spawn a - # second instance on top of it (the consumer would double-poll Postgres). - # Kill both members (the crashed one is already gone → no-op). Same - # all-or-nothing discipline as the restart path. - kill_one_worker "$PG_QUEUE_CONSUMER_TYPE" - kill_one_worker "$PG_QUEUE_REAPER_TYPE" + # second instance on top of it (a consumer would double-poll Postgres). + # Kill all members (a crashed one is already gone → no-op). Same + # all-or-nothing discipline as the restart path — aggregate kill failures + # so a survivor (would double-poll Postgres) is surfaced, not silent. + local teardown_failed=0 + for worker in "${members[@]}"; do + kill_one_worker "$worker" || teardown_failed=1 + done + if [[ $teardown_failed -ne 0 ]]; then + print_status $RED "PG-queue set: a member survived SIGKILL during teardown — check for a duplicate consumer double-polling Postgres" + fi show_status return 1 fi @@ -956,6 +1040,10 @@ SHOW_STATUS=false LOGS_MODE=false CLEAR_LOGS_MODE=false RESTART_MODE=false +# Multiple positional worker types may be given (e.g. `all pg` to start both the +# Celery and PG sets in one shot). WORKER_TYPE stays the first for the +# single-target paths (-r/validation); the launch path loops WORKER_TYPES. +WORKER_TYPES=() while [[ $# -gt 0 ]]; do case $1 in @@ -1021,12 +1109,16 @@ while [[ $# -gt 0 ]]; do exit 1 ;; *) - WORKER_TYPE="$1" + WORKER_TYPES+=("$1") shift ;; esac done +# First positional drives the single-target paths (-r, validation messages); the +# launch path below iterates all of WORKER_TYPES. +WORKER_TYPE="${WORKER_TYPES[0]:-}" + # Handle special actions if [[ "$KILL_WORKERS" == "true" ]]; then kill_workers @@ -1065,13 +1157,18 @@ if [[ "$RESTART_MODE" == "true" ]]; then # consumer would double-poll Postgres. Mirrors kill_workers' discipline. print_status $BLUE "Restarting PG-queue set..." restart_failed=0 - kill_one_worker "$PG_QUEUE_CONSUMER_TYPE" || restart_failed=1 - kill_one_worker "$PG_QUEUE_REAPER_TYPE" || restart_failed=1 + # Kill the SAME members run_pg_queue_set launches (the named roles + + # reaper) — not the generic consumer — or a survivor + relaunch would + # double-poll Postgres. + for pg_member in "${!PG_CONSUMER_ROLES[@]}" "$PG_QUEUE_REAPER_TYPE"; do + kill_one_worker "$pg_member" || restart_failed=1 + done if [[ $restart_failed -ne 0 ]]; then print_status $RED "Cannot restart PG-queue set: a member survived SIGKILL; aborting to avoid duplicate processes" exit 1 fi - elif [[ -n "$WORKER_TYPE" && "$WORKER_TYPE" != "all" ]]; then + elif [[ -n "$WORKER_TYPE" && "$WORKER_TYPE" != "all" && "${WORKERS[$WORKER_TYPE]:-}" != "all" ]]; then + # ('celery' maps to "all" → falls through to the restart-all branch below.) restart_target_dir="${WORKERS[$WORKER_TYPE]:-${PLUGGABLE_WORKERS[$WORKER_TYPE]:-}}" if [[ -z "$restart_target_dir" ]]; then print_status $RED "Error: Unknown worker type for restart: $WORKER_TYPE" @@ -1085,11 +1182,16 @@ if [[ "$RESTART_MODE" == "true" ]]; then fi fi -# Validate worker type -if [[ -z "$WORKER_TYPE" ]]; then - print_status $RED "Error: Worker type is required" - usage - exit 1 +# A worker type is required. (restart-all set WORKER_TYPE=all without a positional +# → seed WORKER_TYPES from it so the launch loop below has a target.) +if [[ ${#WORKER_TYPES[@]} -eq 0 ]]; then + if [[ -n "$WORKER_TYPE" ]]; then + WORKER_TYPES=("$WORKER_TYPE") + else + print_status $RED "Error: Worker type is required" + usage + exit 1 + fi fi # Load environment @@ -1098,23 +1200,25 @@ load_env "$ENV_FILE" # Discover pluggable workers discover_pluggable_workers -# Validate worker type (check both core and pluggable workers) -if [[ -z "${WORKERS[$WORKER_TYPE]}" ]] && [[ -z "${PLUGGABLE_WORKERS[$WORKER_TYPE]}" ]]; then - print_status $RED "Error: Unknown worker type: $WORKER_TYPE" - print_status $BLUE "Available core workers: ${!WORKERS[*]}" - if [[ ${#PLUGGABLE_WORKERS[@]} -gt 0 ]]; then - # Show unique pluggable worker names (not aliases) - pluggable_names="" - for key in "${!PLUGGABLE_WORKERS[@]}"; do - value="${PLUGGABLE_WORKERS[$key]}" - if [[ "$key" == "$value" ]]; then - pluggable_names="$pluggable_names $value" - fi - done - print_status $BLUE "Available pluggable workers:$pluggable_names" +# Validate every requested worker type (core or pluggable) up front. +for wt in "${WORKER_TYPES[@]}"; do + if [[ -z "${WORKERS[$wt]:-}" && -z "${PLUGGABLE_WORKERS[$wt]:-}" ]]; then + print_status $RED "Error: Unknown worker type: $wt" + print_status $BLUE "Available core workers: ${!WORKERS[*]}" + if [[ ${#PLUGGABLE_WORKERS[@]} -gt 0 ]]; then + # Show unique pluggable worker names (not aliases) + pluggable_names="" + for key in "${!PLUGGABLE_WORKERS[@]}"; do + value="${PLUGGABLE_WORKERS[$key]}" + if [[ "$key" == "$value" ]]; then + pluggable_names="$pluggable_names $value" + fi + done + print_status $BLUE "Available pluggable workers:$pluggable_names" + fi + exit 1 fi - exit 1 -fi +done # Validate environment validate_env @@ -1122,19 +1226,25 @@ validate_env # Add PYTHONPATH for imports export PYTHONPATH="$WORKERS_DIR:${PYTHONPATH:-}" -# Run the requested worker(s) -if [[ "$WORKER_TYPE" == "all" ]]; then - run_all_workers "$DETACH" "$LOG_LEVEL" "$CONCURRENCY" "$POOL_TYPE" -elif [[ "${WORKERS[$WORKER_TYPE]:-}" == "$PG_QUEUE_SET" ]]; then - # The PG-queue set (consumer + reaper). Always backgrounded (multiple procs). - # Propagate a member start-failure to the script exit code — the reaper has - # no health port, so this is the only programmatic startup signal. - run_pg_queue_set "$LOG_LEVEL" "$CONCURRENCY" "$POOL_TYPE" || exit 1 -else - # Resolve worker directory name from either WORKERS or PLUGGABLE_WORKERS - WORKER_DIR_NAME="${WORKERS[$WORKER_TYPE]}" - if [[ -z "$WORKER_DIR_NAME" ]]; then - WORKER_DIR_NAME="${PLUGGABLE_WORKERS[$WORKER_TYPE]}" - fi - run_worker "$WORKER_DIR_NAME" "$DETACH" "$LOG_LEVEL" "$CONCURRENCY" "$CUSTOM_QUEUES" "$HEALTH_PORT" "$POOL_TYPE" "$CUSTOM_HOSTNAME" +# Multi-type launches (e.g. `all pg`) must be detached: a non-detached set +# (run_all_workers) `wait`s on its background jobs and would block any later type. +if [[ ${#WORKER_TYPES[@]} -gt 1 && "$DETACH" != "true" ]]; then + print_status $YELLOW "Multiple worker types given without -d/--detach: a foreground set will block the rest. Re-run with -d to start them all." fi + +# Run each requested worker / set in turn. +for wt in "${WORKER_TYPES[@]}"; do + if [[ "${WORKERS[$wt]:-}" == "all" ]]; then + # 'all' and its synonym 'celery' (the Celery set, excludes PG workers). + run_all_workers "$DETACH" "$LOG_LEVEL" "$CONCURRENCY" "$POOL_TYPE" + elif [[ "${WORKERS[$wt]:-}" == "$PG_QUEUE_SET" ]]; then + # The PG-queue set (pipeline roles + reaper). Always backgrounded (multiple + # procs). Propagate a member start-failure to the script exit code — the + # reaper has no health port, so this is the only programmatic startup signal. + run_pg_queue_set "$LOG_LEVEL" "$CONCURRENCY" "$POOL_TYPE" || exit 1 + else + # Resolve worker directory name from either WORKERS or PLUGGABLE_WORKERS + worker_dir_name="${WORKERS[$wt]:-${PLUGGABLE_WORKERS[$wt]}}" + run_worker "$worker_dir_name" "$DETACH" "$LOG_LEVEL" "$CONCURRENCY" "$CUSTOM_QUEUES" "$HEALTH_PORT" "$POOL_TYPE" "$CUSTOM_HOSTNAME" + fi +done diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index 372252770a..c1c50a66e8 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -54,7 +54,7 @@ class TestPollOnce: def test_runs_task_and_acks(self): client = MagicMock() client.read.return_value = [_msg(1, _ok_payload(3, 4))] - assert PgQueueConsumer("q", client=client).poll_once() == 1 + assert PgQueueConsumer(["q"], client=client).poll_once() == 1 assert _calls == [(3, 4)] # task body ran client.delete.assert_called_once_with(1) # acked @@ -64,7 +64,7 @@ def test_failed_task_is_not_acked_and_logs(self, caplog): _msg(2, {"task_name": "test_pg_consumer.boom", "args": [], "kwargs": {}}) ] with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): - PgQueueConsumer("q", client=client).poll_once() + PgQueueConsumer(["q"], client=client).poll_once() client.delete.assert_not_called() # left for vt-expiry redelivery assert "failed" in caplog.text # the cycling signal is logged @@ -74,7 +74,7 @@ def test_unknown_task_is_dropped(self, caplog): _msg(3, {"task_name": "nope.nope", "args": [], "kwargs": {}}) ] with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): - PgQueueConsumer("q", client=client).poll_once() + PgQueueConsumer(["q"], client=client).poll_once() client.delete.assert_called_once_with(3) # dropped, not redelivered assert "unknown task" in caplog.text @@ -82,7 +82,7 @@ def test_missing_task_name_dropped_as_malformed(self, caplog): client = MagicMock() client.read.return_value = [_msg(4, {"args": [], "kwargs": {}})] # no task_name with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): - PgQueueConsumer("q", client=client).poll_once() + PgQueueConsumer(["q"], client=client).poll_once() client.delete.assert_called_once_with(4) assert "missing task_name" in caplog.text # distinct from "unknown task" @@ -93,7 +93,7 @@ def test_poison_message_dropped_past_max_attempts(self, caplog): _msg(5, {"task_name": "test_pg_consumer.boom"}, read_ct=6) ] with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): - PgQueueConsumer("q", client=client, max_attempts=5).poll_once() + PgQueueConsumer(["q"], client=client, max_attempts=5).poll_once() client.delete.assert_called_once_with(5) # dropped as poison assert "poison" in caplog.text @@ -107,7 +107,7 @@ def test_fairness_header_rebuilt_for_run(self): client.read.return_value = [ _msg(6, {"task_name": "t", "args": [1], "kwargs": {"k": "v"}, "fairness": fairness}) ] - PgQueueConsumer("q", client=client, app=app).poll_once() + PgQueueConsumer(["q"], client=client, app=app).poll_once() kwargs = task.apply.call_args.kwargs assert kwargs["args"] == [1] assert kwargs["kwargs"] == {"k": "v"} @@ -118,7 +118,7 @@ def test_ack_finding_no_row_warns(self, caplog): client.delete.return_value = False # row already gone (vt expired mid-run) client.read.return_value = [_msg(7, _ok_payload(1))] with caplog.at_level(logging.WARNING, logger="queue_backend.pg_queue.consumer"): - PgQueueConsumer("q", client=client).poll_once() + PgQueueConsumer(["q"], client=client).poll_once() assert "possible double-run" in caplog.text def test_multi_message_batch(self): @@ -129,7 +129,7 @@ def test_multi_message_batch(self): _msg(12, {"task_name": "nope.nope"}), _msg(13, _ok_payload(2)), ] - assert PgQueueConsumer("q", client=client).poll_once() == 4 + assert PgQueueConsumer(["q"], client=client).poll_once() == 4 assert _calls == [(1, 0), (2, 0)] # ok tasks ran in order deleted = {c.args[0] for c in client.delete.call_args_list} assert deleted == {10, 12, 13} # ok acked + unknown dropped; boom NOT acked @@ -137,7 +137,7 @@ def test_multi_message_batch(self): def test_empty_batch_acks_nothing(self): client = MagicMock() client.read.return_value = [] - assert PgQueueConsumer("q", client=client).poll_once() == 0 + assert PgQueueConsumer(["q"], client=client).poll_once() == 0 client.delete.assert_not_called() @@ -151,13 +151,13 @@ def test_rejects_non_positive_params(self): {"max_attempts": 0}, ): with pytest.raises(ValueError): - PgQueueConsumer("q", client=MagicMock(), **kw) + PgQueueConsumer(["q"], client=MagicMock(), **kw) def test_rejects_backoff_max_below_poll_interval(self): # Otherwise backoff would shrink below poll_interval instead of growing. with pytest.raises(ValueError, match="backoff_max"): PgQueueConsumer( - "q", client=MagicMock(), poll_interval=0.5, backoff_max=0.1 + ["q"], client=MagicMock(), poll_interval=0.5, backoff_max=0.1 ) @@ -165,7 +165,7 @@ class TestRunLoop: def test_run_stops_gracefully(self, monkeypatch): client = MagicMock() client.read.return_value = [] # always empty → backoff path - consumer = PgQueueConsumer("q", client=client) + consumer = PgQueueConsumer(["q"], client=client) # First empty poll → sleep (patched to request stop) → loop exits. monkeypatch.setattr( "queue_backend.pg_queue.consumer.time.sleep", lambda _s: consumer.stop() @@ -178,7 +178,7 @@ def test_backoff_grows_then_resets(self, monkeypatch): # empty, empty, one message, empty → backoff doubles, resets, doubles. client.read.side_effect = [[], [], [_msg(1, _ok_payload(1))], []] consumer = PgQueueConsumer( - "q", client=client, poll_interval=0.1, backoff_max=0.25 + ["q"], client=client, poll_interval=0.1, backoff_max=0.25 ) sleeps: list[float] = [] @@ -196,7 +196,7 @@ def test_poll_error_does_not_kill_loop(self, monkeypatch): client = MagicMock() # first poll raises (transient), then empty → loop must survive the raise. client.read.side_effect = [RuntimeError("blip"), []] - consumer = PgQueueConsumer("q", client=client) + consumer = PgQueueConsumer(["q"], client=client) sleeps: list[float] = [] def _sleep(secs): @@ -214,7 +214,7 @@ def test_run_refuses_to_start_with_empty_registry(self): from celery import Celery empty_app = Celery("empty-no-tasks") # only celery.* built-ins - consumer = PgQueueConsumer("q", client=MagicMock(), app=empty_app) + consumer = PgQueueConsumer(["q"], client=MagicMock(), app=empty_app) with pytest.raises(RuntimeError, match="no application tasks"): consumer.run(install_signals=False) @@ -223,7 +223,7 @@ def test_run_starts_with_registered_tasks(self, monkeypatch): # the module's @shared_task tasks registered, so the guard passes. client = MagicMock() client.read.return_value = [] # empty → loop sleeps → we stop it - consumer = PgQueueConsumer("q", client=client) + consumer = PgQueueConsumer(["q"], client=client) monkeypatch.setattr( "queue_backend.pg_queue.consumer.time.sleep", lambda _s: consumer.stop() ) @@ -238,7 +238,7 @@ def test_run_bypasses_guard_when_require_tasks_false(self, monkeypatch): empty_app = Celery("empty-no-tasks") client = MagicMock() client.read.return_value = [] - consumer = PgQueueConsumer("q", client=client, app=empty_app) + consumer = PgQueueConsumer(["q"], client=client, app=empty_app) monkeypatch.setattr( "queue_backend.pg_queue.consumer.time.sleep", lambda _s: consumer.stop() ) @@ -252,7 +252,7 @@ def test_registered_task_count_excludes_celery_builtins(self): empty_app = Celery("empty-no-tasks") assert ( - PgQueueConsumer("q", client=MagicMock(), app=empty_app)._registered_task_count() + PgQueueConsumer(["q"], client=MagicMock(), app=empty_app)._registered_task_count() == 0 ) @@ -261,7 +261,7 @@ def _demo(): return 1 assert ( - PgQueueConsumer("q", client=MagicMock(), app=empty_app)._registered_task_count() + PgQueueConsumer(["q"], client=MagicMock(), app=empty_app)._registered_task_count() == 1 ) @@ -273,7 +273,7 @@ class TestPollHeartbeat: def test_poll_once_refreshes_heartbeat(self): client = MagicMock() client.read.return_value = [] - consumer = PgQueueConsumer("q", client=client) + consumer = PgQueueConsumer(["q"], client=client) # Simulate a long-idle consumer, then poll → heartbeat resets to ~now. consumer._last_poll_monotonic -= 120 assert consumer.seconds_since_last_poll() > 100 @@ -286,7 +286,7 @@ def test_heartbeat_stamped_before_read(self): # the probe. A bottom-of-poll stamp would pass test_poll_once_refreshes # but fail here. client = MagicMock() - consumer = PgQueueConsumer("q", client=client) + consumer = PgQueueConsumer(["q"], client=client) before = consumer._last_poll_monotonic seen: dict[str, float] = {} client.read.side_effect = lambda *a, **k: ( @@ -297,21 +297,21 @@ def test_heartbeat_stamped_before_read(self): assert seen["during"] > before # refreshed BEFORE read ran, not after def test_is_poll_stale_threshold(self): - consumer = PgQueueConsumer("q", client=MagicMock()) + consumer = PgQueueConsumer(["q"], client=MagicMock()) consumer._last_poll_monotonic -= 120 # last poll 120s ago assert consumer.is_poll_stale(60) is True # past threshold → stale assert consumer.is_poll_stale(200) is False # within threshold → fresh def test_fresh_consumer_is_not_stale(self): # Seeded at construction, so a just-started consumer reads healthy. - consumer = PgQueueConsumer("q", client=MagicMock()) + consumer = PgQueueConsumer(["q"], client=MagicMock()) assert consumer.is_poll_stale(60) is False def test_health_server_disabled_without_port(self): # No port configured → no server bound (opt-in). from queue_backend.pg_queue.consumer import _maybe_start_health_server - consumer = PgQueueConsumer("q", client=MagicMock()) + consumer = PgQueueConsumer(["q"], client=MagicMock()) assert _maybe_start_health_server(consumer, port=None, stale_after=60) is None def test_liveness_server_reports_200_then_503(self): @@ -323,7 +323,7 @@ def test_liveness_server_reports_200_then_503(self): from queue_backend.pg_queue.consumer import LivenessServer - consumer = PgQueueConsumer("q", client=MagicMock()) + consumer = PgQueueConsumer(["q"], client=MagicMock()) server = LivenessServer(consumer, port=0, stale_after=60) server.start() try: @@ -349,7 +349,7 @@ def test_liveness_aliases_and_unknown_path(self): from queue_backend.pg_queue.consumer import LivenessServer - consumer = PgQueueConsumer("q", client=MagicMock()) + consumer = PgQueueConsumer(["q"], client=MagicMock()) server = LivenessServer(consumer, port=0, stale_after=60) server.start() try: @@ -367,7 +367,7 @@ def test_liveness_aliases_and_unknown_path(self): def test_double_start_is_rejected(self): from queue_backend.pg_queue.consumer import LivenessServer - server = LivenessServer(PgQueueConsumer("q", client=MagicMock()), port=0, stale_after=60) + server = LivenessServer(PgQueueConsumer(["q"], client=MagicMock()), port=0, stale_after=60) server.start() try: with pytest.raises(RuntimeError, match="already started"): @@ -391,7 +391,7 @@ def test_enqueue_poll_execute_ack(self, pg_client): queue_name, to_payload("test_pg_consumer.ok", args=[5], kwargs={"y": 6}), ) - claimed = PgQueueConsumer(queue_name, client=pg_client).poll_once() + claimed = PgQueueConsumer([queue_name], client=pg_client).poll_once() assert claimed == 1 assert (5, 6) in _calls # task actually executed off Postgres # Row was acked (deleted) — nothing left to claim. @@ -404,5 +404,65 @@ def test_enqueue_poll_execute_ack(self, pg_client): pg_client.conn.commit() +class TestMultiQueue: + """9f: one consumer drains several queues round-robin.""" + + def test_round_robin_reads_each_queue_and_aggregates(self): + client = MagicMock() + # qa → 2 msgs, qb → 1 msg; poll_once returns the total across both. + client.read.side_effect = [ + [_msg(1, _ok_payload(1)), _msg(2, _ok_payload(2))], + [_msg(3, _ok_payload(3))], + ] + claimed = PgQueueConsumer(["qa", "qb"], client=client).poll_once() + assert claimed == 3 + # Read once per queue, in order. + assert [c.args[0] for c in client.read.call_args_list] == ["qa", "qb"] + + def test_empty_queue_does_not_starve_the_others(self): + client = MagicMock() + client.read.side_effect = [[], [_msg(1, _ok_payload(1))]] + assert PgQueueConsumer(["empty", "busy"], client=client).poll_once() == 1 + + def test_rejects_empty_queue_list(self): + with pytest.raises(ValueError, match="queue_names"): + PgQueueConsumer([], client=MagicMock()) + + def test_one_queue_failing_does_not_abort_the_others(self): + """A read failure on one queue is isolated: the already-acked work this + cycle still counts (so run() doesn't take the empty backoff path), and + the failure doesn't propagate out of poll_once.""" + client = MagicMock() + client.read.side_effect = [[_msg(1, _ok_payload(1))], RuntimeError("boom")] + claimed = PgQueueConsumer(["qa", "qb"], client=client).poll_once() + assert claimed == 1 # qa's message counted despite qb raising + assert _calls == [(1, 0)] # qa task body ran + client.delete.assert_called_once_with(1) # qa acked + + def test_read_uses_batch_size_qty_per_queue(self): + client = MagicMock() + client.read.return_value = [] + PgQueueConsumer(["qa", "qb"], client=client, batch_size=2).poll_once() + assert [c.kwargs["qty"] for c in client.read.call_args_list] == [2, 2] + + def test_duplicate_queues_are_deduped(self): + """A duplicate (e.g. env "q,q") must not double-read the same queue.""" + client = MagicMock() + client.read.return_value = [] + consumer = PgQueueConsumer(["q", "q"], client=client) + assert consumer.queue_names == ["q"] + consumer.poll_once() + assert client.read.call_count == 1 + + +def test_parse_queue_list(): + from queue_backend.pg_queue.consumer import _parse_queue_list + + assert _parse_queue_list("a") == ["a"] # single value → one-element (back-compat) + assert _parse_queue_list("a,b,c") == ["a", "b", "c"] + assert _parse_queue_list(" a , b ,c ") == ["a", "b", "c"] # whitespace stripped + assert _parse_queue_list("a,,b") == ["a", "b"] # empties dropped + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From b2ff1b5a8f8b26f4425814741a7e620627ef3f01 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:43:02 +0530 Subject: [PATCH 23/65] =?UTF-8?q?UN-3576=20[FEAT]=20PG=20Queue=209g=20?= =?UTF-8?q?=E2=80=94=20docker-compose=20PG=20consumer=20+=20reaper=20servi?= =?UTF-8?q?ces=20(gated=20profile)=20(#2074)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3576 [FEAT] PG Queue 9g — docker-compose PG consumer + reaper services (gated profile) Make the already-built PG-queue pipeline runnable under docker compose, gated OFF by default, so it runs in containers (not just the host run-worker script). Designed one-service-per-component for a mechanical K8s mapping later. - run-worker-docker.sh (image ENTRYPOINT): recognise `pg-queue-consumer` and `pg-queue-reaper` launch types -> exec the dedicated Python module instead of building a Celery worker command. Dispatched before the Celery two-path; no Dockerfile change. - docker-compose.yaml: 5 services behind the off-by-default `pg-queue` profile (4 consumers: orchestrator-api / orchestrator-general / fileproc / callback, plus a single-instance reaper). Orchestrators + reaper are broker-free; fileproc + callback keep rabbitmq (they still hand off to the Celery executor / notifications until those are migrated). - sample.env: document the new env vars + profile; the backend transport gate stays off (ramping traffic is a later step). Non-regression: default `docker compose up` starts zero PG services. Even with them running, executions route to Celery until the gate + Flipt flag are set. Co-Authored-By: Claude Opus 4.8 * UN-3576 address review: guard PG interpreter, warn on missing consumer config, reject typo'd PG commands, comment accuracy - run-worker-docker.sh: ensure_pg_interpreter() fails loudly if the venv python is missing (avoids a silent restart:unless-stopped crash-loop); WARN when WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE / _QUEUE are unset (non-compose launches); add a `pg-*|*-reaper` catch arm so a typo'd PG command fails loudly instead of silently becoming a default Celery worker (log-consumer etc. still pass through — verified by routing test). - docker-compose.yaml: soften "broker-free" wording (fail-closed-to-Celery on a missing transport); spell out the executor/notification migration steps instead of the ③/④ markers; use the exact `pg_orchestrator_lock` name. Co-Authored-By: Claude Opus 4.8 * UN-3576 address SonarCloud: add explicit default (*) arm to PG-dispatch case The PG-queue dispatch case relied on an implicit no-match fall-through to the Celery logic below. SonarCloud flags a case without a default; add an explicit `*)` no-op arm documenting the intentional fall-through. Behaviour unchanged (verified by routing test: legit worker types still reach Celery, typo'd PG commands still rejected). Co-Authored-By: Claude Opus 4.8 * UN-3576 address greptile: scope PG near-miss catch to pg- prefix only Narrow the safety-net catch arm from `pg-*|*-reaper` to `pg-*` so it can never intercept a pluggable worker whose name ends in `-reaper` (e.g. a future bulk-reaper / log-history-reaper) — those now fall through to the Celery path. The `pg-` prefix is reserved for PG-queue components; the exact reaper aliases are already matched above, and pg-prefixed typos (pg-reapr, pg-queue-reapr) are still rejected loudly. Verified by routing test. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- docker/docker-compose.yaml | 163 +++++++++++++++++++++++++++++++++++ docker/sample.env | 23 +++++ workers/run-worker-docker.sh | 87 +++++++++++++++++++ 3 files changed, 273 insertions(+) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index b7b49a7618..a85d3e6ca6 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -541,6 +541,169 @@ services: - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config - prompt_studio_data:/app/prompt-studio-data + # =========================================================================== + # PG-queue services (Postgres-backed transport) — gated behind the `pg-queue` + # compose profile, so a plain `docker compose up` starts NONE of them and + # behaviour is identical to today. Bring them up with: + # docker compose --profile pg-queue up -d + # They consume from Postgres, not the broker. The orchestrator consumers and + # the reaper are broker-free for PG-routed executions (fan-out honors the + # carried transport; a missing transport fails closed to Celery, which these + # consumers aren't expected to receive). The fan-out and callback consumers + # still hand off to the Celery executor (tool-execution RPC) / Celery + # notifications, so they keep a rabbitmq dependency until the executor and + # notifications themselves move to PG. Each maps 1:1 to a future K8s Deployment. + # Executions still route to Celery until the backend gate + # (PG_QUEUE_TRANSPORT_ENABLED) + Flipt flag are flipped — that is the ramp, + # not this change. + # =========================================================================== + + # Orchestrator consumer for API-deployment executions (async_execute_bin_api). + worker-pg-orchestrator-api: + image: unstract/worker-unified:${VERSION} + container_name: unstract-worker-pg-orchestrator-api + restart: unless-stopped + command: ["pg-queue-consumer"] + ports: + - "8093:8090" + env_file: + - ../workers/.env + - ./essentials.env + depends_on: + - db + - redis + environment: + - ENVIRONMENT=development + - APPLICATION_NAME=unstract-worker-pg-orchestrator-api + - WORKER_BARRIER_BACKEND=pg + - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=api_deployment + - WORKER_PG_QUEUE_CONSUMER_QUEUE=celery_api_deployments + - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + labels: + - traefik.enable=false + volumes: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + profiles: + - pg-queue + + # Orchestrator consumer for general / scheduled-ETL executions (async_execute_bin). + worker-pg-orchestrator-general: + image: unstract/worker-unified:${VERSION} + container_name: unstract-worker-pg-orchestrator-general + restart: unless-stopped + command: ["pg-queue-consumer"] + ports: + - "8094:8090" + env_file: + - ../workers/.env + - ./essentials.env + depends_on: + - db + - redis + environment: + - ENVIRONMENT=development + - APPLICATION_NAME=unstract-worker-pg-orchestrator-general + - WORKER_BARRIER_BACKEND=pg + - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=general + - WORKER_PG_QUEUE_CONSUMER_QUEUE=celery + - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + labels: + - traefik.enable=false + volumes: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + profiles: + - pg-queue + + # Fan-out consumer (process_file_batch) — both the ETL and API file queues. + worker-pg-fileproc: + image: unstract/worker-unified:${VERSION} + container_name: unstract-worker-pg-fileproc + restart: unless-stopped + command: ["pg-queue-consumer"] + ports: + - "8095:8090" + env_file: + - ../workers/.env + - ./essentials.env + depends_on: + - db + - redis + - rabbitmq # still dispatches the tool-execution RPC to the Celery executor (until the executor/tool-RPC moves to PG) + environment: + - ENVIRONMENT=development + - APPLICATION_NAME=unstract-worker-pg-fileproc + - WORKER_BARRIER_BACKEND=pg + - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=file_processing + - WORKER_PG_QUEUE_CONSUMER_QUEUE=file_processing,api_file_processing + - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + labels: + - traefik.enable=false + volumes: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + profiles: + - pg-queue + + # Fan-in callback consumer (process_batch_callback / _api) — both callback queues. + worker-pg-callback: + image: unstract/worker-unified:${VERSION} + container_name: unstract-worker-pg-callback + restart: unless-stopped + command: ["pg-queue-consumer"] + ports: + - "8096:8090" + env_file: + - ../workers/.env + - ./essentials.env + depends_on: + - db + - redis + - rabbitmq # may dispatch Celery notifications when not PG-routed (until notifications move to PG) + environment: + - ENVIRONMENT=development + - APPLICATION_NAME=unstract-worker-pg-callback + - WORKER_BARRIER_BACKEND=pg + - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=callback + - WORKER_PG_QUEUE_CONSUMER_QUEUE=file_processing_callback,api_file_processing_callback + - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + labels: + - traefik.enable=false + volumes: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + profiles: + - pg-queue + + # Reaper — leader-elected recovery loop. Run exactly ONE instance (it elects a + # single leader via pg_orchestrator_lock; extra replicas idle as standby). + worker-pg-reaper: + image: unstract/worker-unified:${VERSION} + container_name: unstract-worker-pg-reaper + restart: unless-stopped + command: ["pg-queue-reaper"] + ports: + - "8097:8086" + env_file: + - ../workers/.env + - ./essentials.env + depends_on: + - db + - redis + environment: + - ENVIRONMENT=development + - APPLICATION_NAME=unstract-worker-pg-reaper + - WORKER_PG_REAPER_INTERVAL_SECONDS=${WORKER_PG_REAPER_INTERVAL_SECONDS:-5} + - WORKER_PG_REAPER_HEALTH_PORT=8086 + labels: + - traefik.enable=false + volumes: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + profiles: + - pg-queue + volumes: prompt_studio_data: unstract_data: diff --git a/docker/sample.env b/docker/sample.env index 2feb36cf2b..7f114461bd 100644 --- a/docker/sample.env +++ b/docker/sample.env @@ -107,3 +107,26 @@ CIRCUIT_BREAKER_RECOVERY_TIMEOUT=60 HEALTH_CHECK_INTERVAL=30 HEALTH_CHECK_TIMEOUT=10 ENABLE_METRICS=true + +# ============================================================================= +# PG-Queue Services (Postgres-backed transport) — gated, opt-in +# ============================================================================= +# The PG consumer/reaper services live behind the `pg-queue` compose profile and +# are OFF by default. Bring them up with: +# docker compose --profile pg-queue up -d +# They consume from Postgres (not the broker); their worker-type/queue identity +# is set per service in docker-compose.yaml, so nothing here is required to run +# them. The values below are documented for reference / overrides only. +# +# WORKER_BARRIER_BACKEND - fan-in barrier substrate; the PG services set +# this to `pg` (default elsewhere: chord/redis). +# WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE - which worker's tasks the consumer loads. +# WORKER_PG_QUEUE_CONSUMER_QUEUE - comma-separated queues the consumer polls. +# WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT - opt-in consumer liveness port (unset = off). +# WORKER_PG_REAPER_HEALTH_PORT - opt-in reaper liveness port (unset = off). +WORKER_PG_REAPER_INTERVAL_SECONDS=5 # Reaper sweep interval (seconds) +# +# NOTE: routing executions to PG is a SEPARATE, later step. Running these +# services does NOT move any traffic — the backend gate PG_QUEUE_TRANSPORT_ENABLED +# (in backend/.env, default off) plus the Flipt flag still decide per-execution +# transport, and both stay off until the rollout ramp. diff --git a/workers/run-worker-docker.sh b/workers/run-worker-docker.sh index 1b8926a063..be5aa86789 100755 --- a/workers/run-worker-docker.sh +++ b/workers/run-worker-docker.sh @@ -25,6 +25,11 @@ ENV_FILE="/app/.env" # Worker type constant for the executor worker readonly EXECUTOR_WORKER_TYPE="executor" +# Python interpreter for PG-queue components (consumer / reaper). Unlike Celery +# workers, these launch a dedicated module rather than a `celery ... worker` +# command (see run_pg_consumer / run_pg_reaper below). +readonly PG_QUEUE_PYTHON_BIN="/app/.venv/bin/python" + # Available core workers (OSS) declare -A WORKERS=( ["api"]="api_deployment" @@ -486,6 +491,62 @@ run_worker() { exec $celery_cmd $celery_args } +# ============================================================================= +# PG-queue components (Postgres-backed transport) +# ============================================================================= +# These do NOT run a Celery worker — they exec a dedicated Python module that +# polls the Postgres queue. The consumer picks which worker's tasks to register +# and which queues to poll from the environment (set per compose service / +# K8s Deployment); the reaper needs neither. Mirrors the host launcher +# run-worker.sh (`pg-queue-consumer` / `reaper`). + +# Fail loudly if the interpreter is missing (e.g. venv path moved in an image +# refactor) rather than letting `exec` die with a terse "not found" that +# restart:unless-stopped turns into a silent crash-loop. +ensure_pg_interpreter() { + if [[ ! -x "$PG_QUEUE_PYTHON_BIN" ]]; then + print_status $RED "PG-queue interpreter not found/executable: $PG_QUEUE_PYTHON_BIN — check the image build / venv path." + exit 1 + fi +} + +run_pg_consumer() { + ensure_pg_interpreter + local source_type="${WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE:-notification}" + local queues="${WORKER_PG_QUEUE_CONSUMER_QUEUE:-}" + export WORKER_NAME="${WORKER_NAME:-pg-consumer-${source_type}}" + + # Warn (don't fail) on missing config: all compose services set these, but a + # hand-rolled docker run / K8s Deployment that forgets them would otherwise + # start a mislabelled or idle-looking consumer silently. The module logs its + # resolved worker type + queue set at startup, so these stay diagnosable. + if [[ -z "${WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE:-}" ]]; then + print_status $YELLOW "WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE unset — defaulting source worker type to '$source_type'; WORKER_NAME label may not match the module's actual role." + fi + if [[ -z "$queues" ]]; then + print_status $YELLOW "WORKER_PG_QUEUE_CONSUMER_QUEUE unset — consumer will use the module default; check the startup log for the resolved queue set." + fi + + print_status $GREEN "Starting PG-queue consumer..." + print_status $BLUE "Source worker type: $source_type" + print_status $BLUE "Queues: ${queues:-}" + print_status $BLUE "Health port: ${WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT:-}" + + # WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE / _QUEUE are read by the module itself. + exec "$PG_QUEUE_PYTHON_BIN" -m pg_queue_consumer +} + +run_pg_reaper() { + ensure_pg_interpreter + export WORKER_NAME="${WORKER_NAME:-pg-reaper}" + + print_status $GREEN "Starting PG-queue reaper (leader-elected)..." + print_status $BLUE "Interval: ${WORKER_PG_REAPER_INTERVAL_SECONDS:-5}s" + print_status $BLUE "Health port: ${WORKER_PG_REAPER_HEALTH_PORT:-}" + + exec "$PG_QUEUE_PYTHON_BIN" -m pg_queue_reaper +} + # Main execution # Load environment first for any needed variables load_env "$ENV_FILE" @@ -496,6 +557,32 @@ discover_pluggable_workers # Add PYTHONPATH for imports - include both /app and /unstract for packages export PYTHONPATH="/app:/unstract/core/src:/unstract/connectors/src:/unstract/filesystem/src:/unstract/flags/src:/unstract/tool-registry/src:/unstract/tool-sandbox/src:/unstract/workflow-execution/src:${PYTHONPATH:-}" +# PG-queue components run a dedicated module, not a Celery worker — dispatch +# them before the Celery command-building logic below. PYTHONPATH (exported +# above) is required for the module imports. +case "${1:-}" in + pg-queue-consumer|pg-consumer) + run_pg_consumer + ;; + pg-queue-reaper|pg-reaper|reaper) + run_pg_reaper + ;; + pg-*) + # Obviously-PG-intended but unrecognized (e.g. a typo'd command) — fail + # loudly instead of silently coercing it into a default Celery worker. + # Scoped to the `pg-` prefix (reserved for PG-queue components) so it + # never intercepts a pluggable worker name; the exact reaper aliases are + # already matched above. + print_status $RED "Unrecognized PG-queue command: '$1' (did you mean pg-queue-consumer / pg-queue-reaper?)." + exit 1 + ;; + *) + # Not a PG-queue command — intentionally fall through to the Celery + # command logic below (handles every existing worker type + full + # Celery commands). + ;; +esac + # Two-path logic: Full Celery command vs Traditional worker type if [[ "$1" == *"celery"* ]] || [[ "$1" == *".venv"* ]]; then # ============================================================================= From 87f9ede5b6bba185c7d863b84afddd11ddb69ba5 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:50:09 +0530 Subject: [PATCH 24/65] =?UTF-8?q?UN-3581=20[FEAT]=20PG=20Queue=209h-a=20?= =?UTF-8?q?=E2=80=94=20pg=5Fperiodic=5Fschedule=20mirror=20table=20+=20dua?= =?UTF-8?q?l-write=20(inert)=20(#2080)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3581 [FEAT] PG Queue 9h-a — pg_periodic_schedule mirror table + dual-write (inert) First inert slice toward replacing Celery Beat with a PG-backed periodic scheduler (to be folded into the leader-elected reaper/orchestrator loop). This slice only mirrors each scheduled pipeline's cron definition into a new Postgres table; nothing reads it yet, so behaviour is unchanged. - backend/pg_queue/models.py: new PgPeriodicSchedule (pipeline_id PK, org_id, workflow_id, pipeline_name, cron_string, enabled; last_run_at/next_run_at left NULL — the scheduler tick owns all cron computation in the next slice). Index on (enabled, next_run_at) for the future "due schedules" query. - migration 0008 (generated; makemigrations --check clean). - backend/scheduler/tasks.py: dual-write the mirror from the four schedule choke-points (create/update, pause, resume, delete) that already manage the django_celery_beat PeriodicTask. Toggles are placed right after task.save() so a downstream pipeline-status failure can't desync the mirror. Every mirror write is best-effort (try/except + log) — a mirror failure can NEVER break the existing Beat scheduling path. - tests: 6 DB-free unit tests (field extraction, enable/disable, delete, delete-when-PeriodicTask-missing, best-effort swallow). Inert / non-regressive: nothing reads the table; the PeriodicTask + celery-beat container are untouched. Co-Authored-By: Claude Opus 4.8 * UN-3581 address review: mirror real pipeline_name from helper, harden toggles, tighten types - [High] pipeline_name now sourced from the Pipeline object in SchedulerHelper._schedule_task_job (pipeline.pipeline_name), not task_args[6] — which carries the synthetic "Pipeline job-" label, never the user name. The mirror upsert moves to the helper (clean named ids, no positional arg parsing); create_or_update_periodic_task is Beat-only again. - [Med] _mirror_periodic_schedule_set_enabled now bumps updated_at explicitly (queryset .update() does not fire auto_now) and logs when it matches 0 rows (a pre-existing/unmirrored schedule — backfill lands in ②b). - [Med] dropped the positional task_args[N] parsing entirely (the helper passes named fields), removing the silent-placeholder-on-contract-drift risk. - [Low] tightened mirror helper param types (str / str | None) — the callers already pass stringified UUIDs. - [Nit] index renamed pg_sched_due_idx -> pg_periodic_schedule_due_idx (sibling convention); migration 0008 regenerated (not yet merged). - tests: cover the standalone upsert (+enabled=False, +failure-swallow), the helper wiring proving the real pipeline_name flows, disable/enable/delete failure-swallow + still-calls-update_pipeline, and the 0-row log. 11 tests. cron_string validation: the existing 5-field split unpack in create_or_update_periodic_task already gates the Beat path before the mirror runs; full cron semantics are validated by croniter in ②b (the reader). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../migrations/0008_pgperiodicschedule.py | 37 ++++ backend/pg_queue/models.py | 45 ++++ backend/scheduler/helper.py | 13 ++ backend/scheduler/tasks.py | 88 ++++++++ backend/scheduler/tests/__init__.py | 0 .../tests/test_pg_periodic_schedule_mirror.py | 200 ++++++++++++++++++ 6 files changed, 383 insertions(+) create mode 100644 backend/pg_queue/migrations/0008_pgperiodicschedule.py create mode 100644 backend/scheduler/tests/__init__.py create mode 100644 backend/scheduler/tests/test_pg_periodic_schedule_mirror.py diff --git a/backend/pg_queue/migrations/0008_pgperiodicschedule.py b/backend/pg_queue/migrations/0008_pgperiodicschedule.py new file mode 100644 index 0000000000..669fe10457 --- /dev/null +++ b/backend/pg_queue/migrations/0008_pgperiodicschedule.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.1 on 2026-06-18 16:13 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0007_pgbarrierstate_organization_id"), + ] + + operations = [ + migrations.CreateModel( + name="PgPeriodicSchedule", + fields=[ + ("pipeline_id", models.UUIDField(primary_key=True, serialize=False)), + ("organization_id", models.TextField(blank=True, default="")), + ("workflow_id", models.UUIDField(blank=True, null=True)), + ("pipeline_name", models.TextField(blank=True, default="")), + ("cron_string", models.TextField()), + ("enabled", models.BooleanField(default=True)), + ("last_run_at", models.DateTimeField(blank=True, null=True)), + ("next_run_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "pg_periodic_schedule", + "indexes": [ + models.Index( + fields=["enabled", "next_run_at"], + name="pg_periodic_schedule_due_idx", + ) + ], + }, + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index e1f9f3728c..3795339a2d 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -227,3 +227,48 @@ class Meta: # Drives the (future) periodic expiry-sweep job. models.Index(fields=["expires_at"], name="pg_barrier_expires_idx"), ] + + +class PgPeriodicSchedule(models.Model): + """Inert mirror of a scheduled pipeline's cron definition (Phase 9, ②a). + + One row per scheduled pipeline (keyed by ``pipeline_id``), dual-written + alongside the existing ``django_celery_beat`` ``PeriodicTask`` whenever a + pipeline schedule is created/updated/paused/resumed/deleted. It exists so a + future PG-backed scheduler — folded into the leader-elected reaper loop — + can fire due schedules without Celery Beat. + + **Nothing reads this table yet.** This slice only keeps it in sync; the + scheduler tick (SELECT due → enqueue ``execute_pipeline_task`` → recompute + ``next_run_at``) lands in the next slice, which owns all cron computation. + ``last_run_at`` / ``next_run_at`` are therefore left NULL here. + + Managed=True / generated migration, extension-free (UN-3533), same posture + as ``PgBarrierState``. + """ + + # Mirrors the Pipeline PK; also the PeriodicTask ``name`` (str of this UUID). + pipeline_id = models.UUIDField(primary_key=True) + # Owning org — needed to rebuild the ``execute_pipeline_task`` args when the + # scheduler fires. "" = unknown. Same no-NULL-text convention as elsewhere. + organization_id = models.TextField(blank=True, default="") + workflow_id = models.UUIDField(null=True, blank=True) + pipeline_name = models.TextField(blank=True, default="") + cron_string = models.TextField() + # Mirrors PeriodicTask.enabled (pipeline.active / pause / resume). + enabled = models.BooleanField(default=True) + # Owned by the scheduler tick (next slice); NULL until then. + last_run_at = models.DateTimeField(null=True, blank=True) + next_run_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "pg_periodic_schedule" + indexes = [ + # Drives the (future) "due schedules" dequeue in the scheduler tick. + models.Index( + fields=["enabled", "next_run_at"], + name="pg_periodic_schedule_due_idx", + ), + ] diff --git a/backend/scheduler/helper.py b/backend/scheduler/helper.py index ad265ca45e..1b8c68ecf3 100644 --- a/backend/scheduler/helper.py +++ b/backend/scheduler/helper.py @@ -15,6 +15,7 @@ delete_periodic_task, disable_task, enable_task, + mirror_periodic_schedule_upsert, ) logger = logging.getLogger(__name__) @@ -61,6 +62,18 @@ def _schedule_task_job(pipeline: Pipeline, job_data: Any) -> None: ], enabled=pipeline.active, ) + # Inert PG mirror (Phase 9, ②a): sourced from the Pipeline object here so + # it stores the real pipeline_name + ids (the PeriodicTask args carry the + # synthetic "Pipeline job-" label at index 6, not the user name). + # Best-effort — never raises, so Beat scheduling is unaffected. + mirror_periodic_schedule_upsert( + pipeline_id=str(pipeline.pk), + organization_id=organization_id or "", + workflow_id=str(workflow_id), + pipeline_name=pipeline.pipeline_name, + cron_string=cron_string, + enabled=pipeline.active, + ) @staticmethod def add_or_update_job(pipeline: Pipeline) -> None: diff --git a/backend/scheduler/tasks.py b/backend/scheduler/tasks.py index ad8c943069..b07da1aa12 100644 --- a/backend/scheduler/tasks.py +++ b/backend/scheduler/tasks.py @@ -4,7 +4,9 @@ from typing import Any from celery import shared_task +from django.utils import timezone from django_celery_beat.models import CrontabSchedule, PeriodicTask +from pg_queue.models import PgPeriodicSchedule from pipeline_v2.models import Pipeline from pipeline_v2.pipeline_processor import PipelineProcessor from utils.user_context import UserContext @@ -13,6 +15,83 @@ logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# pg_periodic_schedule mirror (Phase 9, ②a) — INERT. +# Dual-writes the schedule definition into pg_periodic_schedule alongside the +# django_celery_beat PeriodicTask, so a future PG-backed scheduler (folded into +# the reaper/orchestrator loop) can fire due schedules without Celery Beat. +# Nothing reads the table yet. Every write is best-effort: a mirror failure must +# NEVER break the existing Beat scheduling path. +# +# The upsert is driven from SchedulerHelper._schedule_task_job (which holds the +# Pipeline object, so it sources the real pipeline_name + clean ids — no parsing +# of the serialized PeriodicTask args). The enable/disable/delete toggles are +# keyed by pipeline_id only, so they live here next to the functions that mutate +# the PeriodicTask. +# --------------------------------------------------------------------------- + + +def mirror_periodic_schedule_upsert( + *, + pipeline_id: str, + organization_id: str, + workflow_id: str | None, + pipeline_name: str, + cron_string: str, + enabled: bool, +) -> None: + try: + PgPeriodicSchedule.objects.update_or_create( + pipeline_id=pipeline_id, + defaults={ + "organization_id": organization_id or "", + "workflow_id": workflow_id or None, + "pipeline_name": pipeline_name or "", + "cron_string": cron_string, + "enabled": enabled, + }, + ) + except Exception: + logger.exception( + f"pg_periodic_schedule mirror upsert failed for pipeline {pipeline_id} " + "(inert mirror — Beat scheduling unaffected)" + ) + + +def _mirror_periodic_schedule_set_enabled(pipeline_id: str, enabled: bool) -> None: + try: + # Bump updated_at explicitly: queryset .update() does NOT trigger the + # field's auto_now, so without this a pause/resume would change enabled + # without advancing the "last changed" timestamp. + matched = PgPeriodicSchedule.objects.filter(pipeline_id=pipeline_id).update( + enabled=enabled, updated_at=timezone.now() + ) + if matched == 0: + # No mirror row — e.g. a pipeline scheduled before this shipped, or + # whose upsert was swallowed. .update() can't self-heal (it only + # touches existing rows); the backfill of such rows lands with the + # scheduler that reads this table (②b). Log so the gap is visible. + logger.info( + f"pg_periodic_schedule mirror enabled={enabled} matched 0 rows for " + f"pipeline {pipeline_id} (not yet mirrored — backfilled in ②b)" + ) + except Exception: + logger.exception( + f"pg_periodic_schedule mirror enabled={enabled} failed for pipeline " + f"{pipeline_id} (inert mirror — Beat scheduling unaffected)" + ) + + +def _mirror_periodic_schedule_delete(pipeline_id: str) -> None: + try: + PgPeriodicSchedule.objects.filter(pipeline_id=pipeline_id).delete() + except Exception: + logger.exception( + f"pg_periodic_schedule mirror delete failed for pipeline {pipeline_id} " + "(inert mirror — Beat scheduling unaffected)" + ) + + def create_or_update_periodic_task( cron_string: str, task_name: str, @@ -49,6 +128,9 @@ def create_or_update_periodic_task( logger.info(f"Created periodic task {periodic_task}") else: logger.info(f"Updated periodic task {periodic_task}") + # The inert PG mirror upsert is driven by the caller + # (SchedulerHelper._schedule_task_job), which has the Pipeline object and so + # sources the real pipeline_name + ids directly (no positional arg parsing). # TODO: Remove unused args with a migration @@ -145,6 +227,8 @@ def delete_periodic_task(task_name: str) -> None: logger.info(f"Deleted periodic task: {task_name}") except PeriodicTask.DoesNotExist: logger.error(f"Periodic task does not exist: {task_name}") + # Clean the inert PG mirror regardless of whether the PeriodicTask existed. + _mirror_periodic_schedule_delete(task_name) def get_periodic_task(task_name: str) -> PeriodicTask | None: @@ -158,6 +242,9 @@ def disable_task(task_name: str) -> None: task = PeriodicTask.objects.get(name=task_name) task.enabled = False task.save() + # Mirror the PeriodicTask.enabled state right after save (before the pipeline + # status update, so a failure there can't desync the inert mirror). + _mirror_periodic_schedule_set_enabled(task_name, False) PipelineProcessor.update_pipeline(task_name, Pipeline.PipelineStatus.PAUSED, False) @@ -165,4 +252,5 @@ def enable_task(task_name: str) -> None: task = PeriodicTask.objects.get(name=task_name) task.enabled = True task.save() + _mirror_periodic_schedule_set_enabled(task_name, True) PipelineProcessor.update_pipeline(task_name, Pipeline.PipelineStatus.RESTARTING, True) diff --git a/backend/scheduler/tests/__init__.py b/backend/scheduler/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py b/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py new file mode 100644 index 0000000000..e5914db655 --- /dev/null +++ b/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py @@ -0,0 +1,200 @@ +"""Unit tests for the inert pg_periodic_schedule mirror (Phase 9, ②a). + +DB-free: ``PgPeriodicSchedule`` (and the django_celery_beat models / serializer) +are mocked, so these pin the dual-write contract — that every schedule mutation +mirrors the right fields, that the mirror stores the *real* pipeline name (not +the synthetic ``"Pipeline job-"`` label carried in the PeriodicTask args), +and that a mirror failure can never break the existing Celery Beat scheduling +path — without a test database. +""" + +from unittest.mock import MagicMock, patch + +from scheduler import tasks +from scheduler.helper import SchedulerHelper + +_PIPELINE_ID = "11111111-1111-1111-1111-111111111111" +_WORKFLOW_ID = "22222222-2222-2222-2222-222222222222" +_ORG = "org_abc" +_REAL_NAME = "Nightly Invoices ETL" + + +class _DoesNotExist(Exception): + """Stand-in for PeriodicTask.DoesNotExist when the model is mocked.""" + + +class TestUpsertMirror: + def test_upserts_with_given_fields(self): + with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + tasks.mirror_periodic_schedule_upsert( + pipeline_id=_PIPELINE_ID, + organization_id=_ORG, + workflow_id=_WORKFLOW_ID, + pipeline_name=_REAL_NAME, + cron_string="0 9 * * *", + enabled=True, + ) + call = sched.objects.update_or_create.call_args + assert call.kwargs["pipeline_id"] == _PIPELINE_ID + defaults = call.kwargs["defaults"] + assert defaults["organization_id"] == _ORG + assert defaults["workflow_id"] == _WORKFLOW_ID + assert defaults["pipeline_name"] == _REAL_NAME + assert defaults["cron_string"] == "0 9 * * *" + assert defaults["enabled"] is True + + def test_disabled_pipeline_mirrors_enabled_false(self): + with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + tasks.mirror_periodic_schedule_upsert( + pipeline_id=_PIPELINE_ID, + organization_id=_ORG, + workflow_id=_WORKFLOW_ID, + pipeline_name=_REAL_NAME, + cron_string="0 9 * * *", + enabled=False, + ) + assert sched.objects.update_or_create.call_args.kwargs["defaults"]["enabled"] is False + + def test_failure_is_swallowed(self): + with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + sched.objects.update_or_create.side_effect = RuntimeError("db down") + # Must not raise. + tasks.mirror_periodic_schedule_upsert( + pipeline_id=_PIPELINE_ID, + organization_id=_ORG, + workflow_id=_WORKFLOW_ID, + pipeline_name=_REAL_NAME, + cron_string="0 9 * * *", + enabled=True, + ) + + +class TestHelperWiringSourcesRealName: + """The High contract: the mirror must store the user-facing pipeline name, + NOT the synthetic ``"Pipeline job-"`` label that the PeriodicTask args + carry at index 6.""" + + def test_schedule_task_job_passes_real_pipeline_name(self): + pipeline = MagicMock() + pipeline.pk = _PIPELINE_ID + pipeline.pipeline_name = _REAL_NAME + pipeline.active = True + pipeline.workflow.id = _WORKFLOW_ID + + serializer = MagicMock() + serializer.get_workflow_id.return_value = _WORKFLOW_ID + serializer.get_execution_action.return_value = "" + + with ( + patch("scheduler.helper.ExecuteWorkflowSerializer", return_value=serializer), + patch( + "scheduler.helper.UserContext.get_organization_identifier", + return_value=_ORG, + ), + patch("scheduler.helper.create_or_update_periodic_task"), + patch("scheduler.helper.mirror_periodic_schedule_upsert") as mirror, + ): + SchedulerHelper._schedule_task_job( + pipeline, + { + "cron_string": "0 9 * * *", + "id": _PIPELINE_ID, + "name": f"Pipeline job-{_PIPELINE_ID}", # the synthetic label + }, + ) + + mirror.assert_called_once() + kwargs = mirror.call_args.kwargs + assert kwargs["pipeline_name"] == _REAL_NAME # real name, not the label + assert kwargs["pipeline_id"] == _PIPELINE_ID + assert kwargs["organization_id"] == _ORG + assert kwargs["enabled"] is True + + +class TestEnableDisableMirror: + def test_disable_mirrors_enabled_false(self): + with ( + patch("scheduler.tasks.PeriodicTask") as pt, + patch("scheduler.tasks.PipelineProcessor"), + patch("scheduler.tasks.PgPeriodicSchedule") as sched, + ): + pt.objects.get.return_value = MagicMock() + sched.objects.filter.return_value.update.return_value = 1 + tasks.disable_task(_PIPELINE_ID) + + sched.objects.filter.assert_called_with(pipeline_id=_PIPELINE_ID) + assert sched.objects.filter.return_value.update.call_args.kwargs["enabled"] is False + + def test_enable_mirrors_enabled_true(self): + with ( + patch("scheduler.tasks.PeriodicTask") as pt, + patch("scheduler.tasks.PipelineProcessor"), + patch("scheduler.tasks.PgPeriodicSchedule") as sched, + ): + pt.objects.get.return_value = MagicMock() + sched.objects.filter.return_value.update.return_value = 1 + tasks.enable_task(_PIPELINE_ID) + + assert sched.objects.filter.return_value.update.call_args.kwargs["enabled"] is True + + def test_disable_mirror_failure_does_not_break_beat_path(self): + """A mirror failure must be swallowed AND the pipeline-status update must + still run (the central 'never break Beat' guarantee).""" + with ( + patch("scheduler.tasks.PeriodicTask") as pt, + patch("scheduler.tasks.PipelineProcessor") as pp, + patch("scheduler.tasks.PgPeriodicSchedule") as sched, + ): + pt.objects.get.return_value = MagicMock() + sched.objects.filter.return_value.update.side_effect = RuntimeError("db down") + tasks.disable_task(_PIPELINE_ID) # must not raise + + pp.update_pipeline.assert_called_once() + + def test_unmirrored_pipeline_logs_zero_match(self, caplog): + import logging + + with ( + patch("scheduler.tasks.PeriodicTask") as pt, + patch("scheduler.tasks.PipelineProcessor"), + patch("scheduler.tasks.PgPeriodicSchedule") as sched, + ): + pt.objects.get.return_value = MagicMock() + sched.objects.filter.return_value.update.return_value = 0 # no mirror row + with caplog.at_level(logging.INFO, logger="scheduler.tasks"): + tasks.disable_task(_PIPELINE_ID) + + assert any("matched 0 rows" in r.message for r in caplog.records) + + +class TestDeleteMirror: + def test_delete_removes_mirror_row(self): + with ( + patch("scheduler.tasks.PeriodicTask") as pt, + patch("scheduler.tasks.PgPeriodicSchedule") as sched, + ): + pt.objects.get.return_value = MagicMock() + tasks.delete_periodic_task(_PIPELINE_ID) + + sched.objects.filter.assert_called_with(pipeline_id=_PIPELINE_ID) + sched.objects.filter.return_value.delete.assert_called_once() + + def test_delete_cleans_mirror_even_when_periodictask_missing(self): + with ( + patch("scheduler.tasks.PeriodicTask") as pt, + patch("scheduler.tasks.PgPeriodicSchedule") as sched, + ): + pt.DoesNotExist = _DoesNotExist + pt.objects.get.side_effect = _DoesNotExist() + tasks.delete_periodic_task(_PIPELINE_ID) # must not raise + + sched.objects.filter.return_value.delete.assert_called_once() + + def test_delete_mirror_failure_is_swallowed(self): + with ( + patch("scheduler.tasks.PeriodicTask") as pt, + patch("scheduler.tasks.PgPeriodicSchedule") as sched, + ): + pt.objects.get.return_value = MagicMock() + sched.objects.filter.return_value.delete.side_effect = RuntimeError("db down") + tasks.delete_periodic_task(_PIPELINE_ID) # must not raise From e202ae0d07d90e170b667fd690e8e6622f291477 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:34:18 +0530 Subject: [PATCH 25/65] =?UTF-8?q?UN-3596=20[FEAT]=20PG=20Queue=209h-b=20?= =?UTF-8?q?=E2=80=94=20PG=20scheduler=20tick=20in=20the=20reaper/orchestra?= =?UTF-8?q?tor=20loop=20(per-schedule=20ownership)=20(#2081)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3596 [FEAT] PG Queue 9h-b — PG scheduler tick in the reaper/orchestrator loop (per-schedule ownership) Adds the periodic-trigger half of the orchestrator: the leader-elected reaper now also fires due, PG-owned schedules onto the PG queue — the Celery Beat replacement — without Beat/RabbitMQ in the trigger path. Dark by default; never double-fires. - pg_scheduler.py (new): dispatch_due_schedules() scans pg_periodic_schedule for pg_owned + enabled + due rows, enqueues scheduler.tasks.execute_pipeline_task on the PG `scheduler` queue AND advances next_run_at in ONE transaction (a crash between can't re-fire). A NULL next_run_at records a baseline and does NOT fire (no burst when a schedule is handed over; matches Beat). A bad cron on one row is logged and skipped without blocking the others. croniter computes next-run; all time comparisons use the DB clock. - pg_queue/models.py + migration 0009: pg_owned flag (default False = Beat owns it; the PG scheduler fires only owned rows) + due index (pg_owned, enabled, next_run_at). Default-false keeps the table inert until a schedule is handed over, and a schedule fires from exactly one side — never both. - reaper.py: the leader tick runs the scheduler AFTER recovery (a scheduler error can't starve the recovery net). - workers deps: add croniter (already a backend dep). - run-worker.sh + docker-compose: pg-scheduler consumer role + service (profile-gated) that runs the fired execute_pipeline_task. Out of scope (next slice ②c): the ramp control that flips pg_owned by percentage + disables the matching Beat PeriodicTask atomically (reusing the existing Flipt mechanism), the one-time backfill, and retiring Beat. Non-regression: pg_owned defaults False, so the reaper fires nothing until rows are explicitly owned; recovery-only behaviour is unchanged. Tests: 10 scheduler (real-PG) + 3 reaper-wiring; full reaper suite kept green via a scheduler stub. Co-Authored-By: Claude Opus 4.8 * UN-3596 address review: per-row DB isolation, self-quiescing bad cron, typed rows, shared INSERT SQL, stronger tests - [Critical] per-row fire (INSERT+UPDATE+commit) now wrapped in try/except → rollback + log + continue, so one bad row can't poison the connection or drop the rest of the batch (mirrors recover_expired_barriers). Baseline UPDATE too. - [High] invalid cron now disables the row (enabled=FALSE) + logs once, instead of re-selecting it and emitting a traceback every ~5s tick forever. - [Med] read step (SELECT now() + due scan) wraps rollback + re-raise so the conn isn't handed back in an aborted-txn state. - [High] softened the "never double-fires" docstring + models.py comment: the guarantee is CONDITIONAL on the ②c ramp control disabling Beat; pre-ramp, safety rests on pg_owned defaulting to False. - [Med] _build_trigger_payload -> TaskPayload; workflow_id/pipeline_id typed str | uuid.UUID (| None); _DueSchedule NamedTuple binds SELECT columns to names at one site (no silent misassign on a reorder). - [Med] extracted INSERT_MESSAGE_SQL constant in client.py; send() and the scheduler share it (no verbatim SQL duplication). - [Low] comment fixes: reaper tick (ordering not isolation), execute_pipeline_task blanks vs Beat populating execution_action, models.py drop "next slice". - tests: fired == 1 (not >= 1, catches double-fire); next_run asserted at the cron's 09:00 match; baseline asserts == 0; +tz-aware next-run; +multi-row (fired == 2); +atomicity (advance UPDATE fails post-INSERT → enqueue rolls back, next_run unchanged); +reaper scheduler-error-discards-owned-conn. 75 green. Co-Authored-By: Claude Opus 4.8 * UN-3596 chore: drop accidentally-committed 9f-design.md (untracked scoping doc) Co-Authored-By: Claude Opus 4.8 * UN-3596 address greptile P1: roll back if the bad-cron disable UPDATE fails _quiesce_invalid_cron used contextlib.suppress around the cursor block, so if the enabled=FALSE UPDATE raised, commit() was skipped and the connection was left in an aborted-transaction state — poisoning the NEXT row's INSERT (caught by the outer handler and mislogged as "failed to fire"). Wrap in try/except with conn.rollback() on failure so the connection is always clean for the next row. +test: a forced disable-UPDATE failure on a bad-cron row doesn't stop a following healthy row from firing. Co-Authored-By: Claude Opus 4.8 * UN-3596 chore: remove superseded 9f-design.md (impl merged in #2073) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- ...e_pg_periodic_schedule_due_idx_and_more.py | 28 ++ backend/pg_queue/models.py | 27 +- docker/docker-compose.yaml | 39 ++- workers/pyproject.toml | 1 + workers/queue_backend/pg_queue/client.py | 16 +- .../queue_backend/pg_queue/pg_scheduler.py | 230 +++++++++++++++ workers/queue_backend/pg_queue/reaper.py | 11 + workers/run-worker.sh | 8 + workers/tests/test_pg_reaper.py | 68 +++++ workers/tests/test_pg_scheduler.py | 276 ++++++++++++++++++ workers/uv.lock | 14 + 11 files changed, 706 insertions(+), 12 deletions(-) create mode 100644 backend/pg_queue/migrations/0009_remove_pgperiodicschedule_pg_periodic_schedule_due_idx_and_more.py create mode 100644 workers/queue_backend/pg_queue/pg_scheduler.py create mode 100644 workers/tests/test_pg_scheduler.py diff --git a/backend/pg_queue/migrations/0009_remove_pgperiodicschedule_pg_periodic_schedule_due_idx_and_more.py b/backend/pg_queue/migrations/0009_remove_pgperiodicschedule_pg_periodic_schedule_due_idx_and_more.py new file mode 100644 index 0000000000..2a37fd54b9 --- /dev/null +++ b/backend/pg_queue/migrations/0009_remove_pgperiodicschedule_pg_periodic_schedule_due_idx_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.1 on 2026-06-18 16:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0008_pgperiodicschedule"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="pgperiodicschedule", + name="pg_periodic_schedule_due_idx", + ), + migrations.AddField( + model_name="pgperiodicschedule", + name="pg_owned", + field=models.BooleanField(default=False), + ), + migrations.AddIndex( + model_name="pgperiodicschedule", + index=models.Index( + fields=["pg_owned", "enabled", "next_run_at"], + name="pg_periodic_schedule_due_idx", + ), + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index 3795339a2d..c079c9f498 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -238,10 +238,12 @@ class PgPeriodicSchedule(models.Model): future PG-backed scheduler — folded into the leader-elected reaper loop — can fire due schedules without Celery Beat. - **Nothing reads this table yet.** This slice only keeps it in sync; the - scheduler tick (SELECT due → enqueue ``execute_pipeline_task`` → recompute - ``next_run_at``) lands in the next slice, which owns all cron computation. - ``last_run_at`` / ``next_run_at`` are therefore left NULL here. + The PG scheduler tick (folded into the leader-elected reaper loop) fires + due rows where ``pg_owned`` is True; rows default to ``pg_owned=False``, so + the table stays inert until a schedule is explicitly handed over to Postgres. + ``last_run_at`` / ``next_run_at`` are owned by that tick (the dual-write + leaves them NULL; a NULL ``next_run_at`` records a baseline on first tick + rather than firing immediately). Managed=True / generated migration, extension-free (UN-3533), same posture as ``PgBarrierState``. @@ -257,7 +259,17 @@ class PgPeriodicSchedule(models.Model): cron_string = models.TextField() # Mirrors PeriodicTask.enabled (pipeline.active / pause / resume). enabled = models.BooleanField(default=True) - # Owned by the scheduler tick (next slice); NULL until then. + # Per-schedule rollout switch. The PG scheduler fires a row ONLY when this is + # True. The no-double-fire guarantee with Celery Beat is CONDITIONAL on the + # matching Beat PeriodicTask being disabled when a schedule is handed over — + # that's the ②c ramp control, which does not exist yet. Until it lands, + # safety rests on this defaulting to False: nothing is pg_owned, so the PG + # scheduler fires nothing and Beat fires everything. Flipping a row True while + # its PeriodicTask is still enabled WOULD double-fire — the ramp control must + # do both atomically. Migrating is then a reversible per-schedule flip. + pg_owned = models.BooleanField(default=False) + # Owned by the scheduler tick. NULL next_run_at = "record a baseline next + # time, don't fire this cycle" (avoids a burst when a schedule is handed over). last_run_at = models.DateTimeField(null=True, blank=True) next_run_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(default=timezone.now) @@ -266,9 +278,10 @@ class PgPeriodicSchedule(models.Model): class Meta: db_table = "pg_periodic_schedule" indexes = [ - # Drives the (future) "due schedules" dequeue in the scheduler tick. + # Drives the "due schedules" scan in the scheduler tick: + # WHERE pg_owned AND enabled AND (next_run_at IS NULL OR <= now()). models.Index( - fields=["enabled", "next_run_at"], + fields=["pg_owned", "enabled", "next_run_at"], name="pg_periodic_schedule_due_idx", ), ] diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index a85d3e6ca6..70579c657d 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -676,8 +676,43 @@ services: profiles: - pg-queue - # Reaper — leader-elected recovery loop. Run exactly ONE instance (it elects a - # single leader via pg_orchestrator_lock; extra replicas idle as standby). + # Scheduler consumer — runs scheduler.tasks.execute_pipeline_task fired by the + # orchestrator's PG scheduler tick (Beat replacement). Distinct from the Celery + # 'scheduler' worker; this only runs the trigger, which then creates the + # execution + dispatches the workflow exactly as today. + worker-pg-scheduler: + image: unstract/worker-unified:${VERSION} + container_name: unstract-worker-pg-scheduler + restart: unless-stopped + command: ["pg-queue-consumer"] + ports: + - "8098:8090" + env_file: + - ../workers/.env + - ./essentials.env + depends_on: + - db + - redis + - rabbitmq # the trigger dispatches async_execute_bin (Celery path when not PG-routed) + environment: + - ENVIRONMENT=development + - APPLICATION_NAME=unstract-worker-pg-scheduler + - WORKER_BARRIER_BACKEND=pg + - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=scheduler + - WORKER_PG_QUEUE_CONSUMER_QUEUE=scheduler + - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + labels: + - traefik.enable=false + volumes: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + profiles: + - pg-queue + + # Reaper / orchestrator — leader-elected loop. Run exactly ONE instance (it + # elects a single leader via pg_orchestrator_lock; extra replicas idle as + # standby). Besides barrier-orphan recovery it runs the PG scheduler tick + # (fires due pg_owned schedules — Beat replacement); dark until rows are owned. worker-pg-reaper: image: unstract/worker-unified:${VERSION} container_name: unstract-worker-pg-reaper diff --git a/workers/pyproject.toml b/workers/pyproject.toml index e3368a4eea..618e9b72b8 100644 --- a/workers/pyproject.toml +++ b/workers/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ # PG Queue — direct Postgres access for the bespoke SKIP LOCKED queue # (reuses the backend's DB_* connection: PgBouncer in cloud, direct in OSS) "psycopg2-binary==2.9.9", # matches backend; first direct-DB worker capability + "croniter>=3.0.3", # cron next-run computation for the PG scheduler tick (matches backend) # Streaming utilities for bulk downloads (pluggable worker) "zipstream-ng>=1.7.0", # Streaming zip creation for memory-efficient bulk downloads # Note: Using dataclasses instead of pydantic for lightweight typing diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py index 8024f11ebc..769ae0b666 100644 --- a/workers/queue_backend/pg_queue/client.py +++ b/workers/queue_backend/pg_queue/client.py @@ -119,6 +119,18 @@ class QueueMessage: read_ct: int +# The whole enqueue contract (columns + their defaults) in one place. `send()` +# appends ``RETURNING msg_id``; the PG scheduler (pg_scheduler.py) executes this +# verbatim inside its own transaction so the enqueue + next_run advance commit +# atomically (it can't call send(), which commits internally). Keep callers in +# sync by sharing this constant rather than copying the SQL. +INSERT_MESSAGE_SQL: str = ( + "INSERT INTO pg_queue_message " + "(queue_name, message, org_id, priority, enqueued_at, vt, read_ct) " + "VALUES (%s, %s::jsonb, %s, %s, now(), now(), 0)" +) + + class PgQueueClient: """``send`` / ``read`` / ``delete`` over ``pg_queue_message``. @@ -199,9 +211,7 @@ def send( ) with self._cursor() as cur: cur.execute( - "INSERT INTO pg_queue_message " - "(queue_name, message, org_id, priority, enqueued_at, vt, read_ct) " - "VALUES (%s, %s::jsonb, %s, %s, now(), now(), 0) RETURNING msg_id", + INSERT_MESSAGE_SQL + " RETURNING msg_id", # "" rather than NULL for "no org" — the column is non-null # (string fields shouldn't have two empty values; Django S6553). ( diff --git a/workers/queue_backend/pg_queue/pg_scheduler.py b/workers/queue_backend/pg_queue/pg_scheduler.py new file mode 100644 index 0000000000..42755e7b05 --- /dev/null +++ b/workers/queue_backend/pg_queue/pg_scheduler.py @@ -0,0 +1,230 @@ +"""PG scheduler tick — the periodic-trigger half of the orchestrator (Phase 9, ②b). + +Folded into the leader-elected reaper loop (the reaper becomes "the +orchestrator": recover + schedule, per the labs single-orchestrator model). +Each cycle, *only while leader*, it scans ``pg_periodic_schedule`` for rows it +owns (``pg_owned``) that are enabled and due, enqueues the existing +``scheduler.tasks.execute_pipeline_task`` onto the PG queue, and advances +``next_run_at`` — replacing what Celery Beat does, without Beat/RabbitMQ. + +Correctness properties: + +- **No re-fire on crash.** The enqueue and the ``next_run_at`` advance happen in + **one transaction**, so a crash between them can't fire twice. +- **One firer per schedule (conditional).** A ``pg_owned`` row fires here; the + no-double-fire guarantee with Beat depends on the matching Beat + ``PeriodicTask`` being disabled when a schedule is handed over — that's the + ②c ramp control, which does not exist yet. **Until it lands, safety rests on + ``pg_owned`` defaulting to False** (nothing is owned → this fires nothing → + Beat fires everything). A row manually flipped to ``pg_owned=True`` while its + PeriodicTask is still enabled *would* double-fire. +- **No burst on hand-over.** A freshly-owned row has ``next_run_at IS NULL``; the + first tick records its baseline next time and does **not** fire (matches Beat: + a new schedule fires at its next cron match, not immediately). + +Per-row isolation (mirrors :func:`recover_expired_barriers`): a bad cron or a DB +error on one row is rolled back, logged, and skipped without poisoning the +connection or blocking the other rows. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import uuid +from datetime import datetime +from typing import TYPE_CHECKING, NamedTuple + +from croniter import croniter + +from unstract.core.data_models import TaskPayload + +from ..fairness import DEFAULT_PRIORITY +from .client import INSERT_MESSAGE_SQL +from .task_payload import to_payload + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + +logger = logging.getLogger(__name__) + +# The fired task + the queue a `scheduler` PG consumer polls (QueueName.SCHEDULER). +PIPELINE_TRIGGER_TASK = "scheduler.tasks.execute_pipeline_task" +SCHEDULER_QUEUE_NAME = "scheduler" + + +class _DueSchedule(NamedTuple): + """One row from the due-schedules scan — names bound to columns at one site + so a future reorder of the SELECT can't silently misassign fields. + """ + + pipeline_id: uuid.UUID + organization_id: str + workflow_id: uuid.UUID | None + pipeline_name: str + cron_string: str + next_run_at: datetime | None + + +def compute_next_run(cron_string: str, base: datetime) -> datetime: + """Next fire time strictly after ``base`` for a 5-field cron expression.""" + return croniter(cron_string, base).get_next(datetime) + + +def _build_trigger_payload( + *, + workflow_id: str | uuid.UUID | None, + organization_id: str, + pipeline_id: str | uuid.UUID, + pipeline_name: str, +) -> TaskPayload: + """``execute_pipeline_task`` payload. Positional args match its signature: + (workflow_id, org_schema, execution_action, execution_id, pipeline_id, + with_logs, name). ``execution_action`` / ``execution_id`` are ignored by + ``execute_pipeline_task_v2``, so we send blanks even though the Beat path + populates ``execution_action`` (see SchedulerHelper._schedule_task_job). + """ + return to_payload( + PIPELINE_TRIGGER_TASK, + args=[ + str(workflow_id) if workflow_id else "", + organization_id or "", + "", # execution_action (ignored by v2) + "", # execution_id (ignored by v2) + str(pipeline_id), + False, # with_logs (ignored by v2) + pipeline_name or "", + ], + kwargs={}, + queue=SCHEDULER_QUEUE_NAME, + fairness=None, + ) + + +def _quiesce_invalid_cron(conn: PgConnection, schedule: _DueSchedule) -> None: + """Disable a row whose cron can't be parsed, so it stops being re-selected + (and re-logging a traceback) every tick. Best-effort; logged once here. + """ + logger.exception( + "PG scheduler: invalid cron %r for pipeline %s — disabling the row", + schedule.cron_string, + schedule.pipeline_id, + ) + try: + with conn.cursor() as cur: + cur.execute( + "UPDATE pg_periodic_schedule SET enabled = FALSE WHERE pipeline_id = %s", + (schedule.pipeline_id,), + ) + conn.commit() + except Exception: + # If the disable UPDATE fails, roll back so the connection isn't left in + # an aborted-transaction state that would poison the next row's INSERT. + with contextlib.suppress(Exception): + conn.rollback() + + +def dispatch_due_schedules(conn: PgConnection) -> int: + """Fire PG-owned, enabled, due schedules; return the count actually fired. + + The caller (reaper tick) gates this on leadership. All time comparisons use + the DB clock (``now()``). Each row is handled in its own transaction; a bad + cron or a DB error on one row is rolled back + logged + skipped (the others + still fire). The read step rolls back + re-raises on error so the connection + is never handed back in an aborted-transaction state (mirrors + :func:`recover_expired_barriers`). + """ + try: + with conn.cursor() as cur: + cur.execute("SELECT now()") + base = cur.fetchone()[0] + cur.execute( + """ + SELECT pipeline_id, organization_id, workflow_id, pipeline_name, + cron_string, next_run_at + FROM pg_periodic_schedule + WHERE pg_owned AND enabled + AND (next_run_at IS NULL OR next_run_at <= %s) + """, + (base,), + ) + due = [_DueSchedule(*row) for row in cur.fetchall()] + conn.commit() + except Exception: + with contextlib.suppress(Exception): + conn.rollback() + raise + + fired = 0 + for schedule in due: + try: + nxt = compute_next_run(schedule.cron_string, base) + except Exception: + _quiesce_invalid_cron(conn, schedule) + continue + + try: + if schedule.next_run_at is None: + # First observation of a freshly-owned row: record the baseline + # next time and do NOT fire (no burst when handed over). + with conn.cursor() as cur: + cur.execute( + "UPDATE pg_periodic_schedule SET next_run_at = %s " + "WHERE pipeline_id = %s", + (nxt, schedule.pipeline_id), + ) + conn.commit() + logger.info( + "PG scheduler: baselined pipeline %s (next_run_at=%s, not fired)", + schedule.pipeline_id, + nxt, + ) + continue + + payload = _build_trigger_payload( + workflow_id=schedule.workflow_id, + organization_id=schedule.organization_id, + pipeline_id=schedule.pipeline_id, + pipeline_name=schedule.pipeline_name, + ) + # Enqueue + advance in ONE transaction so a crash between them can't + # re-fire next cycle. INSERT_MESSAGE_SQL is the shared enqueue + # contract from client.py (send() uses the same constant). + with conn.cursor() as cur: + cur.execute( + INSERT_MESSAGE_SQL, + ( + SCHEDULER_QUEUE_NAME, + json.dumps(payload), + schedule.organization_id or "", + DEFAULT_PRIORITY, + ), + ) + cur.execute( + "UPDATE pg_periodic_schedule " + "SET last_run_at = %s, next_run_at = %s WHERE pipeline_id = %s", + (base, nxt, schedule.pipeline_id), + ) + conn.commit() + except Exception: + # A row-level failure (constraint, serialization, socket) must not + # poison the connection or drop the rest of the batch — roll back + + # leave the row for the next tick (next_run_at unchanged → re-fires). + with contextlib.suppress(Exception): + conn.rollback() + logger.exception( + "PG scheduler: failed to fire pipeline %s — leaving for next tick", + schedule.pipeline_id, + ) + continue + + fired += 1 + logger.info( + "PG scheduler: fired pipeline %s → %s (next_run_at=%s)", + schedule.pipeline_id, + SCHEDULER_QUEUE_NAME, + nxt, + ) + + return fired diff --git a/workers/queue_backend/pg_queue/reaper.py b/workers/queue_backend/pg_queue/reaper.py index 6bf5d7ec2c..d07e2919e5 100644 --- a/workers/queue_backend/pg_queue/reaper.py +++ b/workers/queue_backend/pg_queue/reaper.py @@ -58,6 +58,7 @@ from .connection import create_pg_connection from .leader_election import LeaderLease, default_worker_id from .liveness import LivenessServer as _BaseLivenessServer +from .pg_scheduler import dispatch_due_schedules if TYPE_CHECKING: from psycopg2.extensions import connection as PgConnection @@ -488,6 +489,16 @@ def tick(self) -> TickOutcome: except Exception: self._discard_owned_sweep_conn() raise + # Orchestrator's second job: fire due PG-owned schedules (Beat + # replacement). Ordered AFTER recovery so this cycle's recovery has + # already completed before any scheduler error can propagate (the except + # below still re-raises + discards the conn). Dark by default — fires + # nothing until rows are pg_owned. + try: + dispatch_due_schedules(self._get_sweep_conn()) + except Exception: + self._discard_owned_sweep_conn() + raise return TickOutcome(was_leader=True, reclaimed=reclaimed) def run(self, *, install_signals: bool = True) -> None: diff --git a/workers/run-worker.sh b/workers/run-worker.sh index fee8131aab..91b18c2305 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -55,11 +55,16 @@ readonly PG_ROLE_ORCH_API="pg-orchestrator-api" readonly PG_ROLE_ORCH_GENERAL="pg-orchestrator-general" readonly PG_ROLE_FILEPROC="pg-fileproc" readonly PG_ROLE_CALLBACK="pg-callback" +readonly PG_ROLE_SCHEDULER="pg-scheduler" declare -rA PG_CONSUMER_ROLES=( ["$PG_ROLE_ORCH_API"]="api_deployment;celery_api_deployments" ["$PG_ROLE_ORCH_GENERAL"]="general;celery" ["$PG_ROLE_FILEPROC"]="file_processing;file_processing,api_file_processing" ["$PG_ROLE_CALLBACK"]="callback;file_processing_callback,api_file_processing_callback" + # Runs scheduler.tasks.execute_pipeline_task fired by the orchestrator's PG + # scheduler tick (Beat replacement). Distinct from the Celery 'scheduler' + # worker (which Beat fires onto RabbitMQ). + ["$PG_ROLE_SCHEDULER"]="scheduler;scheduler" ) declare -rA PG_QUEUE_MEMBERS=( ["$PG_QUEUE_CONSUMER_TYPE"]=1 @@ -68,6 +73,7 @@ declare -rA PG_QUEUE_MEMBERS=( ["$PG_ROLE_ORCH_GENERAL"]=1 ["$PG_ROLE_FILEPROC"]=1 ["$PG_ROLE_CALLBACK"]=1 + ["$PG_ROLE_SCHEDULER"]=1 ) # The Celery transport set: every worker EXCEPT the PG-queue members — the # *complement* of the 'pg-queue' set, so the two transports' logs can be tailed @@ -105,6 +111,7 @@ declare -A WORKERS=( ["$PG_ROLE_ORCH_GENERAL"]="$PG_ROLE_ORCH_GENERAL" ["$PG_ROLE_FILEPROC"]="$PG_ROLE_FILEPROC" ["$PG_ROLE_CALLBACK"]="$PG_ROLE_CALLBACK" + ["$PG_ROLE_SCHEDULER"]="$PG_ROLE_SCHEDULER" # PG Queue reaper — leader-elected recovery loop (barrier-orphan sweep) ["reaper"]="$PG_QUEUE_REAPER_TYPE" ["pg-queue-reaper"]="$PG_QUEUE_REAPER_TYPE" @@ -193,6 +200,7 @@ WORKER_TYPE: pg-orchestrator-general Run the PG orchestrator consumer for ETL/general execs (celery) pg-fileproc Run the PG fan-out consumer (file_processing + api_file_processing) pg-callback Run the PG callback consumer (file_processing_callback + api_file_processing_callback) + pg-scheduler Run the PG scheduler consumer (runs execute_pipeline_task fired by the orchestrator) reaper, pg-queue-reaper Run PG-queue reaper (leader-elected recovery; opt-in) pg, pg-queue Run the whole PG-queue set (the 4 pipeline roles + reaper) together all, celery Run the Celery worker set (all Celery workers; excludes the PG set) diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py index ed89fff2ef..ab3b3bdf4d 100644 --- a/workers/tests/test_pg_reaper.py +++ b/workers/tests/test_pg_reaper.py @@ -31,6 +31,18 @@ recover_expired_barriers, ) +# The reaper's leader tick also runs the PG scheduler tick (②b). Its behaviour +# is covered in test_pg_scheduler.py; stub it here by default so the leadership / +# recovery / connection tests aren't coupled to a real schedule query on their +# dummy or barrier-only connections. Tests that assert the wiring opt in via the +# returned mock. +@pytest.fixture(autouse=True) +def stub_scheduler_tick(monkeypatch): + mock = MagicMock(return_value=0) + monkeypatch.setattr(reaper_mod, "dispatch_due_schedules", mock) + return mock + + # --- Layer 1: env + construction (no DB) --- @@ -181,6 +193,62 @@ def boom(): logexc.assert_called_once() +class TestSchedulerTick: + """The orchestrator's second job: the leader (and only the leader) runs the + PG scheduler tick each cycle. Scheduling behaviour itself is in + test_pg_scheduler.py; here we only assert the wiring + leader gating.""" + + def _reaper(self, lease): + return PgReaper( + lease, interval_seconds=0.01, sweep_conn=object(), api_client=object() + ) + + def test_leader_runs_scheduler(self, stub_scheduler_tick): + reaper = self._reaper(_FakeLease(acquires=True, renews=True)) + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): + reaper.tick() + stub_scheduler_tick.assert_called_once() + + def test_standby_does_not_run_scheduler(self, stub_scheduler_tick): + reaper = self._reaper(_FakeLease(acquires=False)) + with patch.object(reaper_mod, "recover_expired_barriers"): + reaper.tick() + stub_scheduler_tick.assert_not_called() + + def test_scheduler_runs_after_recovery(self, stub_scheduler_tick): + # Recovery is the safety net — it must run before scheduling so a + # scheduler error can't starve it. Assert ordering via a shared call log. + order = [] + reaper = self._reaper(_FakeLease(acquires=True, renews=True)) + stub_scheduler_tick.side_effect = lambda *_: order.append("schedule") + with patch.object( + reaper_mod, + "recover_expired_barriers", + side_effect=lambda *_: order.append("recover") or [], + ): + reaper.tick() + assert order == ["recover", "schedule"] + + def test_scheduler_error_discards_owned_conn(self, stub_scheduler_tick): + # A scheduler DB error must propagate (run() catches + continues) AND + # discard the owned sweep conn — same posture as a failed recovery. + # sweep_conn=None → owned; api_client=object() so _get_api_client doesn't + # build a real one (both are evaluated to call the patched recovery). + reaper = PgReaper( + _FakeLease(acquires=True, renews=True), + interval_seconds=0.01, + api_client=object(), + ) + owned = MagicMock() + owned.closed = False # so _get_sweep_conn returns it, doesn't reconnect + reaper._sweep_conn = owned + stub_scheduler_tick.side_effect = psycopg2.OperationalError("db gone") + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): + with pytest.raises(psycopg2.OperationalError): + reaper.tick() + assert reaper._sweep_conn is None # discarded + + # --- Layer 3: connection handling (mocked, no DB) --- diff --git a/workers/tests/test_pg_scheduler.py b/workers/tests/test_pg_scheduler.py new file mode 100644 index 0000000000..75d753b29c --- /dev/null +++ b/workers/tests/test_pg_scheduler.py @@ -0,0 +1,276 @@ +"""Tests for the PG scheduler tick (Phase 9, ②b). + +Pure tests (cron next-run, payload shape) need no DB. The dispatch behaviour is +exercised against real Postgres via the shared ``pg_conn`` fixture (skips when +unreachable/unmigrated). ``dispatch_due_schedules`` commits, so the real-PG +tests seed + clean up their own rows under a unique org marker. +""" + +import datetime +import uuid + +import psycopg2 +import pytest + +from queue_backend.pg_queue.pg_scheduler import ( + SCHEDULER_QUEUE_NAME, + _build_trigger_payload, + compute_next_run, + dispatch_due_schedules, +) + + +class TestPureHelpers: + def test_next_run_is_strictly_after_base(self): + base = datetime.datetime(2026, 6, 18, 10, 0, 0) + # 09:00 daily — already past at 10:00, so the next is tomorrow 09:00. + assert compute_next_run("0 9 * * *", base) == datetime.datetime( + 2026, 6, 19, 9, 0, 0 + ) + + def test_next_run_same_day_when_upcoming(self): + base = datetime.datetime(2026, 6, 18, 8, 0, 0) + assert compute_next_run("0 9 * * *", base) == datetime.datetime( + 2026, 6, 18, 9, 0, 0 + ) + + def test_next_run_preserves_tzaware_base(self): + # Production base is tz-aware (SELECT now()); the result must be aware too. + base = datetime.datetime(2026, 6, 18, 10, 0, 0, tzinfo=datetime.timezone.utc) + nxt = compute_next_run("0 9 * * *", base) + assert nxt.tzinfo is not None + assert nxt == datetime.datetime( + 2026, 6, 19, 9, 0, 0, tzinfo=datetime.timezone.utc + ) + + def test_invalid_cron_raises(self): + with pytest.raises(Exception): + compute_next_run("not a cron", datetime.datetime(2026, 6, 18, 10, 0, 0)) + + def test_trigger_payload_shape(self): + p = _build_trigger_payload( + workflow_id="wf-1", + organization_id="org-1", + pipeline_id="pid-1", + pipeline_name="Nightly ETL", + ) + assert p["task_name"] == "scheduler.tasks.execute_pipeline_task" + assert p["queue"] == SCHEDULER_QUEUE_NAME + # (workflow_id, org, execution_action, execution_id, pipeline_id, with_logs, name) + assert p["args"] == ["wf-1", "org-1", "", "", "pid-1", False, "Nightly ETL"] + assert p["kwargs"] == {} + assert p["fairness"] is None + + +# --- real-PG dispatch behaviour --- + +_MARKER = f"test_pgsched_{uuid.uuid4().hex[:8]}" + + +def _seed(conn, *, pg_owned, enabled, next_run_at, cron="0 9 * * *"): + """Insert one pg_periodic_schedule row; returns its pipeline_id (str).""" + pid = str(uuid.uuid4()) + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO pg_periodic_schedule + (pipeline_id, organization_id, workflow_id, pipeline_name, + cron_string, enabled, pg_owned, last_run_at, next_run_at, + created_at, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, %s, now(), now()) + """, + (pid, _MARKER, str(uuid.uuid4()), "Test ETL", cron, enabled, pg_owned, + next_run_at), + ) + conn.commit() + return pid + + +def _row(conn, pid): + with conn.cursor() as cur: + cur.execute( + "SELECT last_run_at, next_run_at FROM pg_periodic_schedule " + "WHERE pipeline_id = %s", + (pid,), + ) + return cur.fetchone() + + +def _queued_messages(conn): + with conn.cursor() as cur: + cur.execute( + "SELECT message FROM pg_queue_message WHERE queue_name = %s AND org_id = %s", + (SCHEDULER_QUEUE_NAME, _MARKER), + ) + return [r[0] for r in cur.fetchall()] + + +@pytest.fixture +def clean(pg_conn): + """Remove any rows this test created (the tick commits, so teardown must).""" + yield pg_conn + with pg_conn.cursor() as cur: + cur.execute("DELETE FROM pg_periodic_schedule WHERE organization_id = %s", (_MARKER,)) + cur.execute("DELETE FROM pg_queue_message WHERE org_id = %s", (_MARKER,)) + pg_conn.commit() + + +class TestDispatchDueSchedules: + def test_due_owned_row_fires_and_advances(self, clean): + conn = clean + past = datetime.datetime(2020, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + pid = _seed(conn, pg_owned=True, enabled=True, next_run_at=past) + + fired = dispatch_due_schedules(conn) + + assert fired == 1 # fired exactly once (not zero, not double) + msgs = _queued_messages(conn) + assert len(msgs) == 1 + payload = msgs[0] + assert payload["task_name"] == "scheduler.tasks.execute_pipeline_task" + assert payload["args"][4] == pid # pipeline_id at index 4 + last_run, next_run = _row(conn, pid) + assert last_run is not None # fired → last_run stamped + # Advanced to the cron's next match (09:00 UTC), not just "something > past". + assert (next_run.hour, next_run.minute, next_run.second) == (9, 0, 0) + assert next_run > last_run + + def test_null_next_run_baselines_without_firing(self, clean): + conn = clean + pid = _seed(conn, pg_owned=True, enabled=True, next_run_at=None) + + assert dispatch_due_schedules(conn) == 0 # baseline is NOT a fire + assert _queued_messages(conn) == [] + last_run, next_run = _row(conn, pid) + assert last_run is None # never fired + assert next_run is not None # baseline recorded + + def test_two_due_rows_both_fire(self, clean): + conn = clean + past = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + p1 = _seed(conn, pg_owned=True, enabled=True, next_run_at=past) + p2 = _seed(conn, pg_owned=True, enabled=True, next_run_at=past) + + assert dispatch_due_schedules(conn) == 2 + fired_pids = {m["args"][4] for m in _queued_messages(conn)} + assert fired_pids == {p1, p2} + + def test_not_owned_row_is_skipped(self, clean): + conn = clean + past = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + _seed(conn, pg_owned=False, enabled=True, next_run_at=past) + + dispatch_due_schedules(conn) + + assert _queued_messages(conn) == [] + + def test_disabled_row_is_skipped(self, clean): + conn = clean + past = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + _seed(conn, pg_owned=True, enabled=False, next_run_at=past) + + dispatch_due_schedules(conn) + + assert _queued_messages(conn) == [] + + def test_future_next_run_not_yet_due(self, clean): + conn = clean + future = datetime.datetime(2099, 1, 1, tzinfo=datetime.timezone.utc) + _seed(conn, pg_owned=True, enabled=True, next_run_at=future) + + dispatch_due_schedules(conn) + + assert _queued_messages(conn) == [] + + def test_bad_cron_skipped_and_disabled(self, clean): + conn = clean + past = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + bad = _seed(conn, pg_owned=True, enabled=True, next_run_at=past, cron="garbage") + good = _seed(conn, pg_owned=True, enabled=True, next_run_at=past) + + dispatch_due_schedules(conn) + + msgs = _queued_messages(conn) + # Only the good row fired; the bad-cron row was skipped, not fatal. + assert len(msgs) == 1 + assert msgs[0]["args"][4] == good + # The bad-cron row is disabled so it stops being re-selected every tick. + with conn.cursor() as cur: + cur.execute( + "SELECT enabled FROM pg_periodic_schedule WHERE pipeline_id = %s", + (bad,), + ) + assert cur.fetchone()[0] is False + + def test_advance_failure_rolls_back_the_enqueue(self, clean): + """Atomicity: if the next_run_at advance fails after the INSERT, the + INSERT must roll back with it — no orphan message, next_run unchanged + (so the row simply re-fires next tick rather than double-firing).""" + conn = clean + past = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + pid = _seed(conn, pg_owned=True, enabled=True, next_run_at=past) + + # The per-row failure is swallowed (isolation), so dispatch returns 0. + # Fail the fire-path advance (the only UPDATE setting last_run_at). + proxy = _FailingConn(conn, lambda sql: "last_run_at" in sql) + fired = dispatch_due_schedules(proxy) + + assert fired == 0 + assert _queued_messages(conn) == [] # INSERT rolled back with the UPDATE + last_run, next_run = _row(conn, pid) + assert last_run is None # not advanced + assert next_run == past # unchanged → re-fires next tick (no double-fire) + + def test_quiesce_failure_does_not_poison_next_row(self, clean): + """If disabling a bad-cron row fails, the rollback must leave the conn + clean so a following healthy row still fires (greptile P1).""" + conn = clean + past = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + _seed(conn, pg_owned=True, enabled=True, next_run_at=past, cron="garbage") + good = _seed(conn, pg_owned=True, enabled=True, next_run_at=past) + + # Make the bad-cron disable UPDATE fail; the good row must still fire. + proxy = _FailingConn(conn, lambda sql: "enabled = FALSE" in sql) + dispatch_due_schedules(proxy) + + msgs = _queued_messages(conn) + assert len(msgs) == 1 + assert msgs[0]["args"][4] == good + + +class _FailingConn: + """Wraps a real connection so an ``execute`` whose SQL matches ``fail_when`` + raises — to prove a statement failure rolls back cleanly. Everything else + (commit/rollback/other statements) passes through to the real connection. + """ + + def __init__(self, real, fail_when): + self._real = real + self._fail_when = fail_when + + def __getattr__(self, name): + return getattr(self._real, name) # commit / rollback / etc. + + def cursor(self): + return _FailingCursor(self._real.cursor(), self._fail_when) + + +class _FailingCursor: + def __init__(self, cur, fail_when): + self._cur = cur + self._fail_when = fail_when + + def __enter__(self): + self._cur.__enter__() + return self + + def __exit__(self, *exc): + return self._cur.__exit__(*exc) + + def __getattr__(self, name): + return getattr(self._cur, name) # fetchone / fetchall / etc. + + def execute(self, sql, params=None): + if self._fail_when(sql): + raise psycopg2.OperationalError("forced failure") + return self._cur.execute(sql, params) diff --git a/workers/uv.lock b/workers/uv.lock index 6f625ec13b..2be2e6b718 100644 --- a/workers/uv.lock +++ b/workers/uv.lock @@ -875,6 +875,18 @@ wheels = [ { 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]] +name = "croniter" +version = "6.2.2" +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, 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, upload-time = "2026-03-15T08:43:46.626Z" }, +] + [[package]] name = "cryptography" version = "48.0.0" @@ -4900,6 +4912,7 @@ source = { editable = "." } dependencies = [ { name = "boto3" }, { name = "celery" }, + { name = "croniter" }, { name = "httpx" }, { name = "prometheus-client" }, { name = "psutil" }, @@ -4949,6 +4962,7 @@ test = [ requires-dist = [ { name = "boto3", specifier = "~=1.34.0" }, { name = "celery", specifier = ">=5.5.3" }, + { name = "croniter", specifier = ">=3.0.3" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "prometheus-client", specifier = ">=0.17.0,<1.0.0" }, { name = "psutil", specifier = ">=5.9.0,<6.0.0" }, From 7d775cef49f4c1327b4f8770eb0ba2111354971e Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:09:04 +0530 Subject: [PATCH 26/65] =?UTF-8?q?UN-3597=20[FEAT]=20PG=20Queue=209h-c=20?= =?UTF-8?q?=E2=80=94=20schedule=20ownership=20ramp=20control=20+=20backfil?= =?UTF-8?q?l=20(Beat=E2=86=92PG=20hand-over)=20(#2085)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3597 [FEAT] PG Queue 9h-c — schedule ownership ramp control + backfill (Beat→PG hand-over) The ramp control that hands schedules from Celery Beat to the PG scheduler, per-schedule and reversibly. Inert by default (Flipt at 0% / gate off → every schedule stays on Beat), so deploying it changes nothing until ops ramps. - scheduler/ownership.py: resolve_schedule_owner (master-gate PG_QUEUE_TRANSPORT_ ENABLED → pg_scheduler_enabled Flipt flag, keyed on pipeline_id; fail-closed to Beat) + reconcile_ownership_for (ONE transaction: set pg_owned AND PeriodicTask.enabled = active AND NOT pg_owned). Doing both atomically is what makes "never double-fires" real — a pg_owned schedule always has its Beat PeriodicTask disabled. Separate Flipt flag from pg_queue_execution_enabled so scheduling and execution ramp independently. No new env. - scheduler/helper.py: reconcile ownership after the mirror upsert on every schedule create/update (best-effort; a no-op while the rollout is off). - pg_queue management command reconcile_pg_schedules: backfills mirror rows for pre-existing Beat schedules + reconciles all ownership against the current rollout (run once + after each Flipt ramp; idempotent, --dry-run). - tests: 10 ownership (fail-closed matrix + the disable-Beat/pause invariants) + the helper-wiring assertion (21 backend tests green). Dev-tested live on the real DB: hand-over (pg_owned + Beat disabled), rollback, pause; backfill of real unmirrored schedules; and the cross-slice link — a reconciled-to-PG schedule is fired by the ②b reaper. Full container e2e (pg-scheduler consumer → real execution via the live Flipt flag) folded into the ops ramp, alongside the execution canary. Out of scope: stopping the celery-beat container / removing django_celery_beat (ops, once ramped to 100%; final decommission). Co-Authored-By: Claude Opus 4.8 * UN-3597 address review: fix resume double-fire, guard command, atomic-rollback proof, ramp robustness - [HIGH] resume double-fire: enable_task (resume path, separate from reconcile) unconditionally re-enabled Beat — on a pg_owned schedule that meant Beat AND PG both firing. Now sets PeriodicTask.enabled = NOT pg_owned (reads the mirror). - [CRITICAL] reconcile_pg_schedules: per-row json.loads guard (+ isinstance list) so one malformed PeriodicTask.args row logs+skips instead of aborting the whole command (and starving the reconcile step). - [HIGH] command failure reporting: reconcile_ownership_for returns None on a swallowed DB failure; the command tallies failed, writes an ERROR summary, and raises CommandError so automation/ops notice (was: counted failures as success). - [MED] rollback (PG→Beat) now also clears next_run_at, so a later re-hand-over re-enters the NULL baseline instead of bursting on a stale timestamp. - [MED] reconcile_ownership_for: active is keyword-only (boolean-trap guard). - [MED] docstring stale name reconcile_schedule_ownership -> reconcile_ownership_for. - [LOW] flag_key passed by keyword; Flipt-failure log downgraded to warning(exc_info=True) (expected, runs every edit); single f-string; dry-run now previews would-be pg_owned via resolve_schedule_owner (read-only). - tests: enable_task resume (both pg_owned states), next_run_at reset on rollback, None-on-failure, the full command (backfill skip / malformed-args / non-list / dry-run preview / CommandError), and a real-DB atomicity test (force the PeriodicTask update to fail → pg_owned rolls back). 29 backend tests green. Deferred [LOW]: bool-vs-enum return (pg_owned maps directly to the BooleanField; not a wire contract like WorkflowTransport) — see reply. Co-Authored-By: Claude Opus 4.8 * UN-3597 address SonarCloud: reduce reconcile_pg_schedules complexity + drop redundant except - Cognitive Complexity 29→<15: extracted handle() into _backfill_mirrors, _parse_task_args, and _reconcile_all; handle() is now a thin orchestrator. - Dropped the redundant `json.JSONDecodeError` from the except tuple — it's a ValueError subclass, so `except ValueError` already covers both the parse error and the non-array guard. Behaviour unchanged; 5 command tests + the rest green. Co-Authored-By: Claude Opus 4.8 * UN-3597 address SonarCloud: reduce _backfill_mirrors complexity 17→<15 Move the per-arg extraction (the three positional ternaries) out of the backfill loop into _mirror_fields_from_args, which returns the mirror kwargs (or None on a bad row). _backfill_mirrors now just loops + splats the fields, dropping the nested ternaries that drove the cognitive complexity. Behaviour unchanged; command tests green. Co-Authored-By: Claude Opus 4.8 * UN-3597 address greptile: race-safe resume, accurate ramp accounting, updated_at, N+1 - [P1] enable_task lost-update race: task.save() could clobber a concurrent reconcile_ownership_for that flipped pg_owned=True + PeriodicTask.enabled=False → both Beat and PG firing. Now reads pg_owned under select_for_update inside a transaction and writes only the enabled column via .update() (no stale full-row save). Preserves DoesNotExist on a bad name. - [P2] reconcile_ownership_for: when there's no mirror row (updated==0) return False, not the raw resolved pg_owned — PG can't fire without a row, so the effective owner is Beat; returning True would inflate the ramp pg_owned count. - [P2] reconcile_ownership_for now bumps updated_at in the .update() (queryset .update() doesn't fire auto_now), so an ownership flip advances the timestamp. - [P2] reconcile_pg_schedules._backfill_mirrors N+1: pre-fetch all mirrored pipeline ids in one query into a set instead of an EXISTS per PeriodicTask. - tests updated for all four (incl. select_for_update mocking, no-row→False, values_list prefetch). 29 backend tests green; handover + updated_at bump re-verified live. Co-Authored-By: Claude Opus 4.8 * UN-3597 address greptile: add missing import pytest to ownership test TestReconcileAtomicityRealDB calls pytest.skip() when the DB is unavailable, but pytest was never imported — so a DB gap would raise NameError instead of skipping (latent; local runs had a DB so the line was never hit). Add the import. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/pg_queue/management/__init__.py | 0 .../pg_queue/management/commands/__init__.py | 0 .../commands/reconcile_pg_schedules.py | 148 ++++++++++++ .../test_reconcile_pg_schedules_command.py | 122 ++++++++++ backend/scheduler/helper.py | 8 + backend/scheduler/ownership.py | 145 ++++++++++++ backend/scheduler/tasks.py | 21 +- .../tests/test_pg_periodic_schedule_mirror.py | 36 ++- .../tests/test_pg_schedule_ownership.py | 216 ++++++++++++++++++ 9 files changed, 690 insertions(+), 6 deletions(-) create mode 100644 backend/pg_queue/management/__init__.py create mode 100644 backend/pg_queue/management/commands/__init__.py create mode 100644 backend/pg_queue/management/commands/reconcile_pg_schedules.py create mode 100644 backend/pg_queue/tests/test_reconcile_pg_schedules_command.py create mode 100644 backend/scheduler/ownership.py create mode 100644 backend/scheduler/tests/test_pg_schedule_ownership.py diff --git a/backend/pg_queue/management/__init__.py b/backend/pg_queue/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pg_queue/management/commands/__init__.py b/backend/pg_queue/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pg_queue/management/commands/reconcile_pg_schedules.py b/backend/pg_queue/management/commands/reconcile_pg_schedules.py new file mode 100644 index 0000000000..4ee7019d81 --- /dev/null +++ b/backend/pg_queue/management/commands/reconcile_pg_schedules.py @@ -0,0 +1,148 @@ +"""Backfill the pg_periodic_schedule mirror + reconcile Beat/PG schedule +ownership (Phase 9, ②c). + +Run this: +- **once** after deploying the mirror, to backfill rows for schedules created + before the mirror existed (the dual-write only covers schedules touched since); +- **after each Flipt ramp change** to ``pg_scheduler_enabled``, to apply the new + percentage — flipping ``pg_owned`` and the matching Beat ``PeriodicTask`` for + every schedule (the create/update path only reconciles the schedule it edits). + +It is idempotent and safe to run anytime: with the rollout off it leaves every +schedule on Beat. Could later be driven periodically (e.g. by the orchestrator); +kept a command here so the ramp stays an explicit, auditable ops action. +""" + +import json +from typing import Any + +from django.core.management.base import BaseCommand, CommandError +from django_celery_beat.models import CrontabSchedule, PeriodicTask +from scheduler.ownership import reconcile_ownership_for, resolve_schedule_owner +from scheduler.tasks import mirror_periodic_schedule_upsert + +from pg_queue.models import PgPeriodicSchedule + +# Only the pipeline-trigger PeriodicTasks are scheduled pipelines (other periodic +# tasks — metrics, audit — are not mirrored). +_PIPELINE_TASK_PATH = "scheduler.tasks.execute_pipeline_task" + + +def _cron_from_crontab(crontab: CrontabSchedule | None) -> str: + """Reconstruct the 5-field cron string from a CrontabSchedule row.""" + if crontab is None: + return "" + return ( + f"{crontab.minute} {crontab.hour} {crontab.day_of_month} " + f"{crontab.month_of_year} {crontab.day_of_week}" + ) + + +class Command(BaseCommand): + help = ( + "Backfill pg_periodic_schedule mirrors for pre-existing schedules and " + "reconcile Beat/PG ownership against the current pg_scheduler_enabled " + "rollout. Idempotent; with the rollout off, leaves everything on Beat." + ) + + def add_arguments(self, parser: Any) -> None: + parser.add_argument( + "--dry-run", + action="store_true", + help="Report what would change without writing.", + ) + + def handle(self, *args: Any, **options: Any) -> None: + dry_run = options["dry_run"] + backfilled = self._backfill_mirrors(dry_run) + reconciled, pg_owned, failed = self._reconcile_all(dry_run) + + prefix = "[dry-run] " if dry_run else "" + summary = ( + f"{prefix}backfilled={backfilled} reconciled={reconciled} " + f"pg_owned={pg_owned} failed={failed}" + ) + if failed: + # Surface failures where the operator looks (and to automation). + self.stderr.write(self.style.ERROR(summary)) + raise CommandError(f"{failed} schedule(s) failed to reconcile") + self.stdout.write(self.style.SUCCESS(summary)) + + def _mirror_fields_from_args(self, pt: Any, pipeline_id: str) -> dict | None: + """Extract the mirror fields from PeriodicTask.args, or None (logged) for a + malformed/non-array row — a bad row must not abort the whole command. + """ + try: + # json.JSONDecodeError is a ValueError subclass, so one except covers + # both the parse error and the non-array guard below. + task_args = json.loads(pt.args or "[]") + if not isinstance(task_args, list): + raise ValueError(f"expected JSON array, got {type(task_args).__name__}") + except ValueError as exc: + self.stderr.write( + self.style.ERROR( + f"skipping pipeline {pipeline_id}: bad PeriodicTask.args ({exc})" + ) + ) + return None + return { + "workflow_id": task_args[0] if len(task_args) > 0 else None, + "organization_id": (task_args[1] if len(task_args) > 1 else "") or "", + # args[6] is the synthetic "Pipeline job-" label; the real name + # self-heals via the dual-write on the next schedule edit. + "pipeline_name": task_args[6] if len(task_args) > 6 else "", + } + + def _backfill_mirrors(self, dry_run: bool) -> int: + """Create a mirror row for every pipeline-trigger PeriodicTask lacking one.""" + # Pre-fetch the already-mirrored ids in one query (avoid an EXISTS per row). + mirrored = { + str(pk) + for pk in PgPeriodicSchedule.objects.values_list("pipeline_id", flat=True) + } + backfilled = 0 + for pt in PeriodicTask.objects.filter(task=_PIPELINE_TASK_PATH): + pipeline_id = pt.name # = str(pipeline.pk) + if pipeline_id in mirrored: + continue + fields = self._mirror_fields_from_args(pt, pipeline_id) + if fields is None: + continue + self.stdout.write( + f"backfill mirror for pipeline {pipeline_id} (enabled={pt.enabled})" + ) + if not dry_run: + mirror_periodic_schedule_upsert( + pipeline_id=pipeline_id, + cron_string=_cron_from_crontab(pt.crontab), + enabled=pt.enabled, + **fields, + ) + backfilled += 1 + return backfilled + + def _reconcile_all(self, dry_run: bool) -> tuple[int, int, int]: + """Reconcile ownership for every mirror row against the current rollout. + Returns (reconciled, pg_owned, failed). + """ + reconciled = pg_owned = failed = 0 + for row in PgPeriodicSchedule.objects.all(): + if dry_run: + # Preview only — read the would-be owner (no DB write) so an + # operator can see how many a ramp change would hand to PG. + reconciled += 1 + if resolve_schedule_owner(str(row.pipeline_id), row.organization_id): + pg_owned += 1 + continue + # mirror.enabled tracks pipeline.active (dual-write); use it as the + # 'active' input so a paused schedule isn't re-enabled by reconcile. + result = reconcile_ownership_for( + str(row.pipeline_id), row.organization_id, active=row.enabled + ) + if result is None: # transaction failed (already logged) + failed += 1 + continue + reconciled += 1 + if result: + pg_owned += 1 + return reconciled, pg_owned, failed diff --git a/backend/pg_queue/tests/test_reconcile_pg_schedules_command.py b/backend/pg_queue/tests/test_reconcile_pg_schedules_command.py new file mode 100644 index 0000000000..88545e917e --- /dev/null +++ b/backend/pg_queue/tests/test_reconcile_pg_schedules_command.py @@ -0,0 +1,122 @@ +"""Tests for the reconcile_pg_schedules management command (Phase 9, ②c). + +DB-free: the ORM, mirror upsert, and ownership reconcile are mocked. These pin +the operator-facing contract — backfill skip, the malformed-args guard, the +dry-run preview, the counters, and the non-zero exit on failure. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError + +_CMD = "pg_queue.management.commands.reconcile_pg_schedules" + + +def _pt(name, args="[]", enabled=True): + m = MagicMock() + m.name = name + m.args = args + m.enabled = enabled + m.crontab.minute = "0" + m.crontab.hour = "9" + m.crontab.day_of_month = "*" + m.crontab.month_of_year = "*" + m.crontab.day_of_week = "*" + return m + + +def _row(pid, org="org", enabled=True): + m = MagicMock() + m.pipeline_id = pid + m.organization_id = org + m.enabled = enabled + return m + + +class TestReconcileCommand: + def test_backfills_only_unmirrored_and_reconciles(self): + pt_new = _pt("pid-new", args='["wf", "org", "", "", "pid-new", false, "n"]') + pt_exists = _pt("pid-exists") + with ( + patch(f"{_CMD}.PeriodicTask") as PT, + patch(f"{_CMD}.PgPeriodicSchedule") as Sched, + patch(f"{_CMD}.mirror_periodic_schedule_upsert") as upsert, + patch(f"{_CMD}.reconcile_ownership_for", return_value=False) as reconcile, + ): + PT.objects.filter.return_value = [pt_new, pt_exists] + # pid-exists already mirrored; pid-new not (one prefetch query). + Sched.objects.values_list.return_value = ["pid-exists"] + Sched.objects.all.return_value = [_row("pid-new"), _row("pid-exists")] + call_command("reconcile_pg_schedules") + + upsert.assert_called_once() # only the unmirrored one backfilled + assert upsert.call_args.kwargs["pipeline_id"] == "pid-new" + assert reconcile.call_count == 2 # both rows reconciled + + def test_malformed_args_skipped_not_fatal(self): + bad = _pt("pid-bad", args="{ this is not json") + good = _pt("pid-good", args='["wf", "org", "", "", "pid-good", false, "n"]') + with ( + patch(f"{_CMD}.PeriodicTask") as PT, + patch(f"{_CMD}.PgPeriodicSchedule") as Sched, + patch(f"{_CMD}.mirror_periodic_schedule_upsert") as upsert, + patch(f"{_CMD}.reconcile_ownership_for", return_value=False), + ): + PT.objects.filter.return_value = [bad, good] + Sched.objects.values_list.return_value = [] + Sched.objects.all.return_value = [] + # Must not raise despite the bad row. + call_command("reconcile_pg_schedules") + + # Only the good row backfilled; the bad one skipped, not fatal. + assert upsert.call_count == 1 + assert upsert.call_args.kwargs["pipeline_id"] == "pid-good" + + def test_non_list_args_skipped(self): + weird = _pt("pid-weird", args="null") # valid JSON, not a list + with ( + patch(f"{_CMD}.PeriodicTask") as PT, + patch(f"{_CMD}.PgPeriodicSchedule") as Sched, + patch(f"{_CMD}.mirror_periodic_schedule_upsert") as upsert, + patch(f"{_CMD}.reconcile_ownership_for", return_value=False), + ): + PT.objects.filter.return_value = [weird] + Sched.objects.values_list.return_value = [] + Sched.objects.all.return_value = [] + call_command("reconcile_pg_schedules") + + upsert.assert_not_called() + + def test_dry_run_writes_nothing_but_previews_owner(self): + with ( + patch(f"{_CMD}.PeriodicTask") as PT, + patch(f"{_CMD}.PgPeriodicSchedule") as Sched, + patch(f"{_CMD}.mirror_periodic_schedule_upsert") as upsert, + patch(f"{_CMD}.reconcile_ownership_for") as reconcile, + patch(f"{_CMD}.resolve_schedule_owner", return_value=True) as resolve, + ): + PT.objects.filter.return_value = [ + _pt("pid-1", args='["wf", "org", "", "", "pid-1", false, "n"]') + ] + Sched.objects.values_list.return_value = [] + Sched.objects.all.return_value = [_row("pid-1")] + call_command("reconcile_pg_schedules", "--dry-run") + + upsert.assert_not_called() # no backfill write + reconcile.assert_not_called() # no ownership write + resolve.assert_called_once() # but the would-be owner is previewed + + def test_failure_raises_command_error(self): + with ( + patch(f"{_CMD}.PeriodicTask") as PT, + patch(f"{_CMD}.PgPeriodicSchedule") as Sched, + patch(f"{_CMD}.mirror_periodic_schedule_upsert"), + patch(f"{_CMD}.reconcile_ownership_for", return_value=None), # failed + ): + PT.objects.filter.return_value = [] + Sched.objects.values_list.return_value = [] + Sched.objects.all.return_value = [_row("pid-1")] + with pytest.raises(CommandError): + call_command("reconcile_pg_schedules") diff --git a/backend/scheduler/helper.py b/backend/scheduler/helper.py index 1b8c68ecf3..d4ea878c42 100644 --- a/backend/scheduler/helper.py +++ b/backend/scheduler/helper.py @@ -9,6 +9,7 @@ from scheduler.constants import SchedulerConstants as SC from scheduler.exceptions import JobDeletionError, JobSchedulingError +from scheduler.ownership import reconcile_ownership_for from scheduler.serializer import AddJobSerializer from scheduler.tasks import ( create_or_update_periodic_task, @@ -74,6 +75,13 @@ def _schedule_task_job(pipeline: Pipeline, job_data: Any) -> None: cron_string=cron_string, enabled=pipeline.active, ) + # Ramp control (Phase 9, ②c): align this schedule's firer (Beat vs PG) + # with the rollout. Inert until the gate + pg_scheduler_enabled flag are + # on (fails closed to Beat), so this is a no-op during normal operation; + # when a schedule is owned by PG it disables the Beat PeriodicTask in the + # same transaction so the two never both fire. Best-effort (never raises). + # organization_id is the org identifier string (what the mirror stores). + reconcile_ownership_for(str(pipeline.pk), organization_id, active=pipeline.active) @staticmethod def add_or_update_job(pipeline: Pipeline) -> None: diff --git a/backend/scheduler/ownership.py b/backend/scheduler/ownership.py new file mode 100644 index 0000000000..8331cf1c2a --- /dev/null +++ b/backend/scheduler/ownership.py @@ -0,0 +1,145 @@ +"""Schedule-ownership ramp control (Phase 9, ②c) — hands a pipeline's schedule +from Celery Beat to the Postgres scheduler, per-schedule and reversibly. + +A schedule is owned by exactly one firer. ``reconcile_ownership_for`` +applies that decision atomically: + + pg_periodic_schedule.pg_owned = resolve_schedule_owner(...) # PG fires it + PeriodicTask.enabled = active AND NOT pg_owned # Beat fires it + +Doing both in one transaction is what makes "never double-fires" real (it was +*conditional* on this slice): a ``pg_owned`` row always has its Beat +``PeriodicTask`` disabled, so the two can't both fire. + +Inert by default: ``resolve_schedule_owner`` fails closed to Beat +(``pg_owned=False``) until ops turns the master gate on AND ramps the +``pg_scheduler_enabled`` Flipt flag — so reconciling on every schedule edit is a +no-op (everything stays Beat-owned) until the rollout starts. +""" + +from __future__ import annotations + +import logging +import os + +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from django_celery_beat.models import PeriodicTask +from pg_queue.models import PgPeriodicSchedule + +from unstract.flags.feature_flag import check_feature_flag_status + +logger = logging.getLogger(__name__) + +# Independent of the execution-transport flag (pg_queue_execution_enabled) so +# scheduling and execution ramp separately. %-rollout keyed on pipeline_id. +SCHEDULER_FLAG_KEY = "pg_scheduler_enabled" + + +def resolve_schedule_owner(pipeline_id: str, organization_id: str | None) -> bool: + """True → the PG scheduler owns this schedule; False → Celery Beat does. + + Mirrors ``resolve_transport``: master-gated by ``PG_QUEUE_TRANSPORT_ENABLED`` + (shared PG kill-switch), then the ``pg_scheduler_enabled`` Flipt flag, keyed + on ``pipeline_id`` for a stable percentage bucket. **Fails closed to Beat** + on a closed gate, a blind Flipt, or any error — so a schedule never silently + loses its firer. + """ + # Master gate off → never consult Flipt; every schedule stays on Beat. + if not settings.PG_QUEUE_TRANSPORT_ENABLED: + return False + + if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": + logger.warning( + "resolve_schedule_owner: gate ON but FLIPT_SERVICE_AVAILABLE != true " + "(Flipt blind) for pipeline %s; leaving on Beat", + pipeline_id, + ) + return False + + # Flipt context is a gRPC map; coerce values to str (a non-str + # makes the client swallow it as False). entity_id str-coerced too so the + # %-rollout bucket is stable across str/UUID call sites. + context = {"pipeline_id": str(pipeline_id)} + if organization_id: + context["organization_id"] = str(organization_id) + try: + owned = check_feature_flag_status( + flag_key=SCHEDULER_FLAG_KEY, entity_id=str(pipeline_id), context=context + ) + except Exception: + # Expected, recoverable (fail-closed to Beat) and runs on every schedule + # edit — warn with traceback rather than logger.exception so a persistently + # down Flipt doesn't bury real errors as a per-edit Sentry exception. + logger.warning( + "resolve_schedule_owner: Flipt check failed for pipeline %s; " + "leaving on Beat", + pipeline_id, + exc_info=True, + ) + return False + return bool(owned) + + +def reconcile_ownership_for( + pipeline_id: str, organization_id: str | None, *, active: bool +) -> bool | None: + """Align one schedule's firer (Beat vs PG) with the current rollout decision. + + In one transaction: set ``pg_owned`` from :func:`resolve_schedule_owner` and + set the Beat ``PeriodicTask.enabled = active AND NOT pg_owned`` — so a + schedule handed to PG has Beat disabled (and vice-versa on rollback), with no + window where both fire. On rollback (``pg_owned`` → False) ``next_run_at`` is + also cleared so a later re-hand-over re-enters the PG tick's NULL baseline + (no burst). ``organization_id`` is the org *identifier* string (what the + mirror stores), used for Flipt per-org segmenting. ``active`` is keyword-only + (a fire/don't-fire boolean trap otherwise). + + Best-effort: a DB failure is logged and swallowed so it can never break the + caller. Returns the resolved ``pg_owned`` on success, or **None** if the + transaction failed (so the ramp command can tally + surface failures). + """ + pg_owned = resolve_schedule_owner(pipeline_id, organization_id) + try: + with transaction.atomic(): + # queryset .update() doesn't fire auto_now, so bump updated_at + # explicitly (mirrors _mirror_periodic_schedule_set_enabled). + updates: dict = {"pg_owned": pg_owned, "updated_at": timezone.now()} + if not pg_owned: + # Back on Beat → clear the PG next-run so a future re-hand-over + # baselines instead of firing immediately on a stale timestamp. + updates["next_run_at"] = None + # The mirror row exists from the dual-write (②a) / backfill; guard + # anyway — a missing row means nothing to own yet. + updated = PgPeriodicSchedule.objects.filter(pipeline_id=pipeline_id).update( + **updates + ) + if updated == 0: + logger.info( + "reconcile_ownership_for: no mirror row for pipeline %s " + "(not yet mirrored); skipping", + pipeline_id, + ) + # Without a mirror row PG can't fire (nothing to tick) and the Beat + # PeriodicTask was never disabled → the effective owner is Beat. + # Return False so the ramp count isn't inflated past what's live. + return False + # Beat owns it only when active AND not handed to PG. + PeriodicTask.objects.filter(name=pipeline_id).update( + enabled=active and not pg_owned + ) + logger.info( + "reconcile_ownership_for: pipeline %s pg_owned=%s (beat_enabled=%s)", + pipeline_id, + pg_owned, + active and not pg_owned, + ) + return pg_owned + except Exception: + logger.exception( + "reconcile_ownership_for failed for pipeline %s — schedule stays on " + "its current firer until the next reconcile", + pipeline_id, + ) + return None diff --git a/backend/scheduler/tasks.py b/backend/scheduler/tasks.py index b07da1aa12..86cf8a7a16 100644 --- a/backend/scheduler/tasks.py +++ b/backend/scheduler/tasks.py @@ -4,6 +4,7 @@ from typing import Any from celery import shared_task +from django.db import transaction from django.utils import timezone from django_celery_beat.models import CrontabSchedule, PeriodicTask from pg_queue.models import PgPeriodicSchedule @@ -249,8 +250,22 @@ def disable_task(task_name: str) -> None: def enable_task(task_name: str) -> None: - task = PeriodicTask.objects.get(name=task_name) - task.enabled = True - task.save() + PeriodicTask.objects.get(name=task_name) # preserve DoesNotExist on a bad name + # Resume → the schedule is active again, but Beat must fire it ONLY when it's + # not handed to PG — else a pg_owned schedule would fire from both Beat and PG + # (the ②c ownership invariant; reconcile sets the same on create/update, but + # resume takes this path). Lock the mirror row + write only the `enabled` + # column (not a full task.save() of stale state) so a concurrent + # reconcile_ownership_for can't be clobbered into a double-fire. + with transaction.atomic(): + pg_owned = ( + PgPeriodicSchedule.objects.select_for_update() + .filter(pipeline_id=task_name) + .values_list("pg_owned", flat=True) + .first() + or False + ) + PeriodicTask.objects.filter(name=task_name).update(enabled=not pg_owned) + # mirror.enabled tracks pipeline.active (True on resume) regardless of owner. _mirror_periodic_schedule_set_enabled(task_name, True) PipelineProcessor.update_pipeline(task_name, Pipeline.PipelineStatus.RESTARTING, True) diff --git a/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py b/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py index e5914db655..ea56ccea5b 100644 --- a/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py +++ b/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py @@ -8,6 +8,7 @@ path — without a test database. """ +from contextlib import nullcontext from unittest.mock import MagicMock, patch from scheduler import tasks @@ -93,6 +94,7 @@ def test_schedule_task_job_passes_real_pipeline_name(self): ), patch("scheduler.helper.create_or_update_periodic_task"), patch("scheduler.helper.mirror_periodic_schedule_upsert") as mirror, + patch("scheduler.helper.reconcile_ownership_for") as reconcile, ): SchedulerHelper._schedule_task_job( pipeline, @@ -109,6 +111,9 @@ def test_schedule_task_job_passes_real_pipeline_name(self): assert kwargs["pipeline_id"] == _PIPELINE_ID assert kwargs["organization_id"] == _ORG assert kwargs["enabled"] is True + # ②c: ownership is reconciled after the mirror upsert (org-identifier + + # active passed through; active is keyword-only). + reconcile.assert_called_once_with(_PIPELINE_ID, _ORG, active=True) class TestEnableDisableMirror: @@ -125,17 +130,42 @@ def test_disable_mirrors_enabled_false(self): sched.objects.filter.assert_called_with(pipeline_id=_PIPELINE_ID) assert sched.objects.filter.return_value.update.call_args.kwargs["enabled"] is False - def test_enable_mirrors_enabled_true(self): + @staticmethod + def _set_pg_owned(sched, value): + # enable_task reads pg_owned via select_for_update().filter().values_list().first() + ( + sched.objects.select_for_update.return_value.filter.return_value.values_list.return_value.first.return_value + ) = value + + def test_resume_enables_beat_when_not_pg_owned(self): with ( patch("scheduler.tasks.PeriodicTask") as pt, patch("scheduler.tasks.PipelineProcessor"), patch("scheduler.tasks.PgPeriodicSchedule") as sched, + patch("scheduler.tasks.transaction.atomic", return_value=nullcontext()), ): pt.objects.get.return_value = MagicMock() - sched.objects.filter.return_value.update.return_value = 1 + self._set_pg_owned(sched, False) + tasks.enable_task(_PIPELINE_ID) + + # Beat enabled via a column-only .update() (no full task.save() to clobber) + assert pt.objects.filter.return_value.update.call_args.kwargs["enabled"] is True + + def test_resume_keeps_beat_disabled_when_pg_owned(self): + """The High bug: resuming a PG-owned schedule must NOT re-enable Beat + (both firing = double-fire).""" + with ( + patch("scheduler.tasks.PeriodicTask") as pt, + patch("scheduler.tasks.PipelineProcessor"), + patch("scheduler.tasks.PgPeriodicSchedule") as sched, + patch("scheduler.tasks.transaction.atomic", return_value=nullcontext()), + ): + pt.objects.get.return_value = MagicMock() + self._set_pg_owned(sched, True) tasks.enable_task(_PIPELINE_ID) - assert sched.objects.filter.return_value.update.call_args.kwargs["enabled"] is True + # PG owns it → Beat stays off. + assert pt.objects.filter.return_value.update.call_args.kwargs["enabled"] is False def test_disable_mirror_failure_does_not_break_beat_path(self): """A mirror failure must be swallowed AND the pipeline-status update must diff --git a/backend/scheduler/tests/test_pg_schedule_ownership.py b/backend/scheduler/tests/test_pg_schedule_ownership.py new file mode 100644 index 0000000000..ed83030a81 --- /dev/null +++ b/backend/scheduler/tests/test_pg_schedule_ownership.py @@ -0,0 +1,216 @@ +"""Unit tests for the schedule-ownership ramp control (Phase 9, ②c). + +DB-free: Flipt, settings, and the ORM (``PgPeriodicSchedule`` / ``PeriodicTask``) +are mocked. These pin the fail-closed rollout decision and — the load-bearing +property — that handing a schedule to PG disables its Beat ``PeriodicTask`` in +the same step (no double-fire), with pause state preserved. +""" + +import contextlib +from unittest.mock import MagicMock, patch + +import pytest + +from scheduler import ownership + +_PID = "11111111-1111-1111-1111-111111111111" +_ORG = "org_abc" + + +def _gate(on: bool): + s = MagicMock() + s.PG_QUEUE_TRANSPORT_ENABLED = on + return patch("scheduler.ownership.settings", s) + + +class TestResolveScheduleOwner: + def test_gate_off_is_beat(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with _gate(False), patch( + "scheduler.ownership.check_feature_flag_status" + ) as flag: + assert ownership.resolve_schedule_owner(_PID, _ORG) is False + flag.assert_not_called() # gate off → Flipt never consulted + + def test_flipt_unavailable_is_beat(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") + with _gate(True), patch( + "scheduler.ownership.check_feature_flag_status" + ) as flag: + assert ownership.resolve_schedule_owner(_PID, _ORG) is False + flag.assert_not_called() + + def test_flag_true_is_pg(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with _gate(True), patch( + "scheduler.ownership.check_feature_flag_status", return_value=True + ) as flag: + assert ownership.resolve_schedule_owner(_PID, _ORG) is True + # entity_id = pipeline_id (stable %-bucket); org in context. + assert flag.call_args.kwargs["entity_id"] == _PID + assert flag.call_args.kwargs["context"]["pipeline_id"] == _PID + assert flag.call_args.kwargs["context"]["organization_id"] == _ORG + + def test_flag_false_is_beat(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with _gate(True), patch( + "scheduler.ownership.check_feature_flag_status", return_value=False + ): + assert ownership.resolve_schedule_owner(_PID, _ORG) is False + + def test_flipt_error_fails_closed_to_beat(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with _gate(True), patch( + "scheduler.ownership.check_feature_flag_status", + side_effect=RuntimeError("flipt down"), + ): + assert ownership.resolve_schedule_owner(_PID, _ORG) is False + + +class TestReconcileOwnership: + def _patches(self, *, owner: bool, rows_matched: int = 1): + sched = patch("scheduler.ownership.PgPeriodicSchedule") + pt = patch("scheduler.ownership.PeriodicTask") + resolve = patch( + "scheduler.ownership.resolve_schedule_owner", return_value=owner + ) + # transaction.atomic() as a no-op context manager. + txn = patch( + "scheduler.ownership.transaction.atomic", + return_value=contextlib.nullcontext(), + ) + return sched, pt, resolve, txn + + def test_pg_owned_disables_beat_periodictask(self): + sched, pt, resolve, txn = self._patches(owner=True) + with sched as Sched, pt as PT, resolve, txn: + Sched.objects.filter.return_value.update.return_value = 1 + result = ownership.reconcile_ownership_for(_PID, _ORG, active=True) + + assert result is True + # mirror pg_owned set True + assert ( + Sched.objects.filter.return_value.update.call_args.kwargs["pg_owned"] + is True + ) + # Beat PeriodicTask disabled (active AND NOT pg_owned == False) + PT.objects.filter.assert_called_once_with(name=_PID) + assert ( + PT.objects.filter.return_value.update.call_args.kwargs["enabled"] is False + ) + + def test_not_pg_owned_enables_beat_and_clears_next_run(self): + sched, pt, resolve, txn = self._patches(owner=False) + with sched as Sched, pt as PT, resolve, txn: + Sched.objects.filter.return_value.update.return_value = 1 + ownership.reconcile_ownership_for(_PID, _ORG, active=True) + + update_kwargs = Sched.objects.filter.return_value.update.call_args.kwargs + assert update_kwargs["pg_owned"] is False + # Rollback to Beat clears next_run_at so a re-hand-over re-baselines. + assert update_kwargs["next_run_at"] is None + assert ( + PT.objects.filter.return_value.update.call_args.kwargs["enabled"] is True + ) + + def test_pg_owned_does_not_clear_next_run(self): + sched, pt, resolve, txn = self._patches(owner=True) + with sched as Sched, pt, resolve, txn: + Sched.objects.filter.return_value.update.return_value = 1 + ownership.reconcile_ownership_for(_PID, _ORG, active=True) + + # An active PG-owned schedule must NOT have its next_run_at reset (that + # would re-baseline and skip a fire). + assert ( + "next_run_at" + not in Sched.objects.filter.return_value.update.call_args.kwargs + ) + + def test_paused_pipeline_keeps_beat_disabled_even_if_not_pg_owned(self): + sched, pt, resolve, txn = self._patches(owner=False) + with sched as Sched, pt as PT, resolve, txn: + Sched.objects.filter.return_value.update.return_value = 1 + ownership.reconcile_ownership_for(_PID, _ORG, active=False) + + # active=False → Beat stays disabled regardless of ownership. + assert ( + PT.objects.filter.return_value.update.call_args.kwargs["enabled"] is False + ) + + def test_missing_mirror_row_skips_and_reports_beat(self): + sched, pt, resolve, txn = self._patches(owner=True) + with sched as Sched, pt as PT, resolve, txn: + Sched.objects.filter.return_value.update.return_value = 0 # no row + # No mirror row → PG can't fire → effective owner is Beat → returns + # False even though resolve said PG (so the ramp count isn't inflated). + assert ownership.reconcile_ownership_for(_PID, _ORG, active=True) is False + + PT.objects.filter.assert_not_called() # nothing to own yet + + def test_failure_returns_none_and_is_swallowed(self): + sched, pt, resolve, txn = self._patches(owner=True) + with sched as Sched, pt, resolve, txn: + Sched.objects.filter.return_value.update.side_effect = RuntimeError("db") + # Must not raise, and signals failure (None) so the ramp can tally it. + assert ownership.reconcile_ownership_for(_PID, _ORG, active=True) is None + + +class TestReconcileAtomicityRealDB: + """The load-bearing invariant: the pg_owned write and the PeriodicTask write + are ONE transaction — if the PeriodicTask update fails, pg_owned rolls back + (so a schedule can't end up pg_owned with Beat still enabled). Needs a real + DB (the mocked atomic() can't prove rollback); skips if unreachable.""" + + def test_periodictask_update_failure_rolls_back_pg_owned(self): + import uuid + + from django_celery_beat.models import CrontabSchedule + from django_celery_beat.models import PeriodicTask as RealPeriodicTask + from pg_queue.models import PgPeriodicSchedule + + try: + cron, _ = CrontabSchedule.objects.get_or_create( + minute="0", + hour="9", + day_of_week="*", + day_of_month="*", + month_of_year="*", + ) + except Exception as exc: # pragma: no cover - infra-dependent + pytest.skip(f"DB unavailable: {exc}") + + pid = str(uuid.uuid4()) + RealPeriodicTask.objects.create( + name=pid, + task="scheduler.tasks.execute_pipeline_task", + crontab=cron, + enabled=True, + args="[]", + ) + PgPeriodicSchedule.objects.create( + pipeline_id=pid, + organization_id="org_atomic", + cron_string="0 9 * * *", + enabled=True, + pg_owned=False, + ) + try: + # Force the second write (the Beat PeriodicTask update) to fail; the + # pg_owned write (real, before it in the same atomic) must roll back. + failing_pt = MagicMock() + failing_pt.objects.filter.return_value.update.side_effect = RuntimeError( + "beat update fail" + ) + with ( + patch("scheduler.ownership.resolve_schedule_owner", return_value=True), + patch("scheduler.ownership.PeriodicTask", failing_pt), + ): + result = ownership.reconcile_ownership_for( + pid, "org_atomic", active=True + ) + assert result is None # failure signalled + # The pg_owned=True write was rolled back with the failed PT update. + assert PgPeriodicSchedule.objects.get(pipeline_id=pid).pg_owned is False + finally: + RealPeriodicTask.objects.filter(name=pid).delete() + PgPeriodicSchedule.objects.filter(pipeline_id=pid).delete() From ca73756a6fd15a83f7214e8951816d11dd544dcd Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:47:47 +0530 Subject: [PATCH 27/65] =?UTF-8?q?UN-3602=20[FIX]=20PG=20Queue=20=E2=80=94?= =?UTF-8?q?=20restore=20API-deployment=20timeout=20sync-wait=20on=20the=20?= =?UTF-8?q?PG=20path=20(#2086)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3602 [FIX] PG Queue — restore API-deployment timeout sync-wait on the PG path On the PG transport, async_execute_bin dispatch returns the bigint pg_queue_message msg_id as the handle. Writing it into WorkflowExecution.task_id (a UUIDField) raised ValueError on save, which the broad post-dispatch handler swallowed and returned EXECUTING — silently skipping the synchronous timeout poll loop. Every PG-routed API deployment ignored `timeout` and returned immediately. - add nullable BigIntegerField queue_message_id to WorkflowExecution (migration 0020; metadata-only AddField, no table rewrite, safe on large tables) - store the PG msg_id in queue_message_id; task_id stays NULL on the PG path - new WorkflowHelper._record_dispatch_handle routes the handle by transport, called inside its own try/except so post-dispatch bookkeeping can never skip the timeout wait again (the structural root cause) - Celery path unchanged Co-Authored-By: Claude Opus 4.8 * UN-3602 [FIX] address review — defensive PG handle parse + regression test Addresses PR #2086 review (toolkit + greptile): - _record_dispatch_handle: @classmethod -> @staticmethod (never uses cls) - parse the PG msg_id defensively (try/except TypeError/ValueError) so a malformed/future handle format logs a specific cause instead of being absorbed by the caller's generic post-dispatch guard - update_execution_queue_message_id annotation int -> int | None (matches the None guard in the body; mirrors update_execution_task) - add the missing regression test (test_execute_workflow_async_wait.py): a raise during handle recording must NOT skip the timeout poll loop - add a non-numeric-PG-handle test; scope the routing-test docstring - comment precision: the ValueError was raised inside update_execution_task on save (UUID coercion), not at the call site Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../workflow_manager/workflow_v2/execution.py | 26 ++++++ ...0020_workflowexecution_queue_message_id.py | 21 +++++ .../workflow_v2/models/execution.py | 6 ++ .../tests/test_execute_workflow_async_wait.py | 67 ++++++++++++++ .../tests/test_record_dispatch_handle.py | 92 +++++++++++++++++++ .../workflow_v2/workflow_helper.py | 87 ++++++++++++++---- 6 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 backend/workflow_manager/workflow_v2/migrations/0020_workflowexecution_queue_message_id.py create mode 100644 backend/workflow_manager/workflow_v2/tests/test_execute_workflow_async_wait.py create mode 100644 backend/workflow_manager/workflow_v2/tests/test_record_dispatch_handle.py diff --git a/backend/workflow_manager/workflow_v2/execution.py b/backend/workflow_manager/workflow_v2/execution.py index d06cb06738..83a1c24057 100644 --- a/backend/workflow_manager/workflow_v2/execution.py +++ b/backend/workflow_manager/workflow_v2/execution.py @@ -425,6 +425,32 @@ def update_execution_task(execution_id: str, task_id: str) -> None: except WorkflowExecution.DoesNotExist: logger.error(f"execution doesn't exist {execution_id}") + @staticmethod + def update_execution_queue_message_id( + execution_id: str, queue_message_id: int | None + ) -> None: + """Record the PG queue-row handle (``pg_queue_message.msg_id``) on the + execution. PG-only: ``task_id`` is a UUIDField that can't hold the bigint + msg_id, so the PG handle lives in its own ``queue_message_id`` column. + """ + try: + if queue_message_id is None: + logger.warning( + f"Skipped setting queue_message_id for execution {execution_id} " + "since it's None" + ) + return + + execution = WorkflowExecution.objects.get(pk=execution_id) + execution.queue_message_id = queue_message_id + execution.save(update_fields=["queue_message_id"]) + logger.info( + f"Successfully set queue_message_id '{queue_message_id}' for " + f"execution {execution_id}" + ) + except WorkflowExecution.DoesNotExist: + logger.error(f"execution doesn't exist {execution_id}") + @staticmethod def convert_tool_instance_model_to_data_class( tool_instance: ToolInstance, diff --git a/backend/workflow_manager/workflow_v2/migrations/0020_workflowexecution_queue_message_id.py b/backend/workflow_manager/workflow_v2/migrations/0020_workflowexecution_queue_message_id.py new file mode 100644 index 0000000000..4d1b8bf5b6 --- /dev/null +++ b/backend/workflow_manager/workflow_v2/migrations/0020_workflowexecution_queue_message_id.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.1 on 2026-06-19 05:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow_v2", "0019_remove_filehistory_trigram_index"), + ] + + operations = [ + migrations.AddField( + model_name="workflowexecution", + name="queue_message_id", + field=models.BigIntegerField( + db_comment="pg_queue_message.msg_id for PG-transport executions (the queue-row handle; task_id stays NULL on the PG path)", + editable=False, + null=True, + ), + ), + ] diff --git a/backend/workflow_manager/workflow_v2/models/execution.py b/backend/workflow_manager/workflow_v2/models/execution.py index 262b191a2c..3d35d6f0b8 100644 --- a/backend/workflow_manager/workflow_v2/models/execution.py +++ b/backend/workflow_manager/workflow_v2/models/execution.py @@ -145,6 +145,12 @@ class Type(models.TextChoices): null=True, db_comment="task id of asynchronous execution", ) + queue_message_id = models.BigIntegerField( + editable=False, + null=True, + db_comment="pg_queue_message.msg_id for PG-transport executions " + "(the queue-row handle; task_id stays NULL on the PG path)", + ) workflow = models.ForeignKey( Workflow, on_delete=models.CASCADE, diff --git a/backend/workflow_manager/workflow_v2/tests/test_execute_workflow_async_wait.py b/backend/workflow_manager/workflow_v2/tests/test_execute_workflow_async_wait.py new file mode 100644 index 0000000000..74586ab533 --- /dev/null +++ b/backend/workflow_manager/workflow_v2/tests/test_execute_workflow_async_wait.py @@ -0,0 +1,67 @@ +"""Regression test for UN-3602 — the defense-in-depth guarantee. + +The bug: post-dispatch bookkeeping (recording the dispatch handle) raised, and +because it lived inside the broad post-dispatch ``try/except``, the handler +returned ``EXECUTING`` immediately and SKIPPED the synchronous ``timeout`` poll +loop. The fix moves that bookkeeping into its own ``try/except`` so a failure +there can never abort the wait. + +This test pins that: when ``_record_dispatch_handle`` raises, +``execute_workflow_async`` must still enter the poll loop (i.e. call +``_get_execution_status``) instead of short-circuiting to an immediate +``EXECUTING``. Without the inner ``try/except``, this test fails. + +DB-free: the model, transport resolution, context, and ``time.sleep`` are mocked. +""" + +from unittest.mock import MagicMock, patch + +from workflow_manager.workflow_v2.enums import ExecutionStatus +from workflow_manager.workflow_v2.workflow_helper import WorkflowHelper + +_MOD = "workflow_manager.workflow_v2.workflow_helper" + + +class TestTimeoutWaitSurvivesBookkeepingFailure: + def test_record_handle_raise_does_not_skip_timeout_poll(self): + exec_row = MagicMock(status=ExecutionStatus.EXECUTING.value) + + with ( + patch(f"{_MOD}.resolve_transport", return_value="pg_queue"), + patch(f"{_MOD}.UserContext") as user_ctx, + patch(f"{_MOD}.StateStore") as state_store, + patch(f"{_MOD}.time"), # no-op sleep + patch(f"{_MOD}.WorkflowExecution") as wf_exec, + patch.object( + WorkflowHelper, "_dispatch_orchestrator_task", return_value="1" + ), + patch.object( + WorkflowHelper, + "_record_dispatch_handle", + side_effect=RuntimeError("bookkeeping boom"), + ) as record_handle, + patch.object( + WorkflowHelper, + "_get_execution_status", + return_value=ExecutionStatus.EXECUTING, + ) as get_status, + ): + user_ctx.get_organization_identifier.return_value = "org1" + state_store.get.return_value = None + wf_exec.objects.get.return_value = exec_row + + response = WorkflowHelper.execute_workflow_async( + workflow_id="wf-1", + execution_id="exec-1", + hash_values_of_files={}, + timeout=2, # > -1 so the poll loop is supposed to run + ) + + # The bookkeeping was attempted and raised — and was swallowed. + record_handle.assert_called_once() + # The load-bearing assertion: the poll loop STILL ran despite the raise. + # If the recording were back outside the inner try/except, the broad + # handler would have returned EXECUTING immediately and this would be 0. + assert get_status.called + # And the call returned normally (no propagated exception). + assert response is not None diff --git a/backend/workflow_manager/workflow_v2/tests/test_record_dispatch_handle.py b/backend/workflow_manager/workflow_v2/tests/test_record_dispatch_handle.py new file mode 100644 index 0000000000..e21c835dc4 --- /dev/null +++ b/backend/workflow_manager/workflow_v2/tests/test_record_dispatch_handle.py @@ -0,0 +1,92 @@ +"""Tests for WorkflowHelper._record_dispatch_handle — the post-dispatch +bookkeeping that records the transport's dispatch handle on the execution row. + +Regression context: the PG path returns ``str(msg_id)`` (a bigint string). It +used to be written into ``WorkflowExecution.task_id`` (a UUIDField), raising +``ValueError`` on save → the broad post-dispatch handler swallowed it and +returned EXECUTING immediately, silently skipping the ``timeout`` sync-wait. The +fix routes the PG msg_id into its own ``queue_message_id`` (BigIntegerField) and +leaves ``task_id`` NULL on the PG path, so no bigint is ever forced into a UUID. + +DB-free: ``WorkflowExecutionServiceHelper`` is mocked. These assert ROUTING +only (PG -> ``queue_message_id`` as int, Celery -> ``task_id``, malformed/empty +handle -> neither); the UUID-coercion crash and the timeout sync-wait are NOT +reproduced here (the helper is mocked). The defense-in-depth guarantee — a raise +during handle recording must not skip the wait loop — is covered separately in +``test_execute_workflow_async_wait.py``. +""" + +from unittest.mock import patch + +from workflow_manager.workflow_v2.workflow_helper import WorkflowHelper + +_HELPER = ( + "workflow_manager.workflow_v2.workflow_helper.WorkflowExecutionServiceHelper" +) +_EXEC = "exec-123" + + +class TestRecordDispatchHandle: + def test_pg_handle_goes_to_queue_message_id_as_int_not_task_id(self): + """The PG msg_id (bigint string) must be stored in queue_message_id as an + int — never in the UUID task_id (the bug that crashed the wait loop).""" + with patch(_HELPER) as helper: + WorkflowHelper._record_dispatch_handle( + execution_id=_EXEC, + transport="pg_queue", + dispatch_handle="1527", + org_schema="org1", + file_count=3, + ) + helper.update_execution_queue_message_id.assert_called_once_with( + execution_id=_EXEC, queue_message_id=1527 + ) + assert isinstance( + helper.update_execution_queue_message_id.call_args.kwargs[ + "queue_message_id" + ], + int, + ) + helper.update_execution_task.assert_not_called() # task_id stays NULL + + def test_celery_handle_goes_to_task_id_not_queue_message_id(self): + with patch(_HELPER) as helper: + WorkflowHelper._record_dispatch_handle( + execution_id=_EXEC, + transport="celery", + dispatch_handle="b1b2c3d4-0000-0000-0000-000000000000", + org_schema="org1", + file_count=1, + ) + helper.update_execution_task.assert_called_once_with( + execution_id=_EXEC, task_id="b1b2c3d4-0000-0000-0000-000000000000" + ) + helper.update_execution_queue_message_id.assert_not_called() + + def test_empty_handle_records_nothing(self): + with patch(_HELPER) as helper: + WorkflowHelper._record_dispatch_handle( + execution_id=_EXEC, + transport="celery", + dispatch_handle=None, + org_schema="org1", + file_count=0, + ) + helper.update_execution_task.assert_not_called() + helper.update_execution_queue_message_id.assert_not_called() + + def test_non_numeric_pg_handle_records_nothing(self): + """A malformed PG handle (not a bigint) must be parsed defensively — no + ValueError out of the helper, and nothing recorded. Today this can't + happen (``_dispatch_orchestrator_task`` always returns ``str(msg_id)``), + so this pins the contract against a future handle-format change.""" + with patch(_HELPER) as helper: + WorkflowHelper._record_dispatch_handle( + execution_id=_EXEC, + transport="pg_queue", + dispatch_handle="not-a-number", + org_schema="org1", + file_count=1, + ) + helper.update_execution_queue_message_id.assert_not_called() + helper.update_execution_task.assert_not_called() diff --git a/backend/workflow_manager/workflow_v2/workflow_helper.py b/backend/workflow_manager/workflow_v2/workflow_helper.py index da868b76f2..0e2e28aacd 100644 --- a/backend/workflow_manager/workflow_v2/workflow_helper.py +++ b/backend/workflow_manager/workflow_v2/workflow_helper.py @@ -517,6 +517,56 @@ def _dispatch_orchestrator_task( ) return async_execution.id + @staticmethod + def _record_dispatch_handle( + *, + execution_id: str, + transport: str, + dispatch_handle: str | None, + org_schema: str, + file_count: int, + ) -> None: + """Persist the transport's dispatch handle on the execution row. + + Celery → the Celery task UUID into ``task_id`` (UUIDField). PG → the + ``pg_queue_message.msg_id`` into ``queue_message_id`` (BigIntegerField); + ``task_id`` stays NULL because there is no Celery task on the PG path. + Each id lives in its own correctly-typed column so a bigint msg_id is + never forced into the UUID ``task_id``. + """ + if not dispatch_handle: + # PG always yields a truthy msg_id, so an empty handle is Celery-only. + logger.warning( + f"[{org_schema}] Empty dispatch handle (transport={transport}) " + f"for execution_id '{execution_id}'." + ) + return + if is_pg_transport(transport): + # The PG handle is the bigint msg_id as a string. Parse defensively + # so a malformed/future handle format surfaces its specific cause + # here instead of being absorbed by the caller's generic guard. + try: + msg_id = int(dispatch_handle) + except (TypeError, ValueError): + logger.error( + f"[{org_schema}] PG dispatch handle {dispatch_handle!r} is not " + f"a valid bigint msg_id for execution_id '{execution_id}'; " + "queue_message_id not recorded" + ) + return + WorkflowExecutionServiceHelper.update_execution_queue_message_id( + execution_id=execution_id, queue_message_id=msg_id + ) + else: + WorkflowExecutionServiceHelper.update_execution_task( + execution_id=execution_id, task_id=dispatch_handle + ) + logger.info( + f"[{org_schema}] Job '{dispatch_handle}' enqueued " + f"(transport={transport}) for execution_id '{execution_id}', " + f"'{file_count}' files" + ) + @classmethod def execute_workflow_async( cls, @@ -597,7 +647,7 @@ def execute_workflow_async( # Orchestrator transport (9e PR A / 2d): dispatch async_execute_bin on # the resolved transport (PG enqueue vs Celery). Extracted to a helper # so this method stays simple and the fork is unit-testable. - task_id = cls._dispatch_orchestrator_task( + dispatch_handle = cls._dispatch_orchestrator_task( transport=transport, queue=queue, args=dispatch_args, @@ -611,23 +661,26 @@ def execute_workflow_async( workflow_execution: WorkflowExecution = WorkflowExecution.objects.get( id=execution_id ) - if not task_id: - # PG always yields a truthy msg_id, so an empty id is Celery-only; - # keep the transport in the message so operators can tell. - logger.warning( - f"[{org_schema}] Empty task_id (transport={transport}) for " - f"execution_id '{execution_id}'." - ) - # Continue without setting task_id - execution can still complete - else: - # Use existing method to handle task_id setting with validation - WorkflowExecutionServiceHelper.update_execution_task( - execution_id=execution_id, task_id=task_id + # Record the dispatch handle (Celery task_id or PG msg_id) on the row. + # Best-effort, in its OWN try/except: it must NEVER abort the + # synchronous timeout wait below. (Writing a bigint msg_id into the + # UUID task_id used to raise ValueError inside update_execution_task + # (UUID coercion on save), which bubbled to the post-dispatch handler + # and silently skipped the wait — so every PG-routed API deployment + # ignored `timeout`.) + try: + cls._record_dispatch_handle( + execution_id=execution_id, + transport=transport, + dispatch_handle=dispatch_handle, + org_schema=org_schema or "", + file_count=len(hash_values_of_files), ) - logger.info( - f"[{org_schema}] Job '{task_id}' enqueued (transport={transport}) " - f"for execution_id '{execution_id}', " - f"'{len(hash_values_of_files)}' files" + except Exception: + logger.exception( + f"[{org_schema}] Failed to record dispatch handle " + f"(transport={transport}) for execution '{execution_id}'; " + "continuing — the orchestrator is already running" ) execution_status = workflow_execution.status From fd38637812144e74fc7d662a81bc23486ddb8dc9 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:56:57 +0530 Subject: [PATCH 28/65] =?UTF-8?q?UN-3603=20[GATED-FEAT]=20PG=20Queue=20?= =?UTF-8?q?=E2=80=94=20blocking=20executor=20RPC=20on=20Postgres=20+=20uni?= =?UTF-8?q?fy=20gating=20to=20a=20single=20flag=20(#2094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3603 [GATED-FEAT] PG Queue — blocking executor RPC on Postgres + unify gating to a single flag A parallel PG-native executor RPC for prompt-studio's blocking dispatch, gated by the single pg_queue_enabled flag. The SDK ExecutionDispatcher and the Celery executor worker are left completely untouched. - pg_task_result store (migration 0010) + PgResultBackend (store / poll-wait; idempotent ON CONFLICT; poll-based, PgBouncer-safe — no LISTEN/NOTIFY) - reply_key on the shared TaskPayload contract + backend producer - consumer result-write hook: request-reply messages store result/error + ack after one attempt; fire-and-forget path unchanged (guarded by reply_key) - backend executor_rpc: resolve_executor_transport gate + PgExecutionDispatcher (enqueue + poll, mirrors the SDK dispatch contract) + RoutingExecutionDispatcher; _get_dispatcher returns the routing dispatcher so all call sites stay unchanged - worker-pg-executor role (run-worker.sh + docker-compose; broker-free) - unify PG-queue gating to a SINGLE flag pg_queue_enabled: execution, scheduler, and executor all read one key (renamed from pg_queue_execution_enabled / pg_scheduler_enabled / pg_executor_enabled). One flip gates the whole feature; the PG_QUEUE_TRANSPORT_ENABLED env stays the master kill-switch. Gated off by default. Dev-tested live: on->PG / off->Celery cycle (COMPLETED both ways) for API + ETL; executor RPC round-trip through worker-pg-executor. Co-Authored-By: Claude Opus 4.8 * UN-3603 [GATED-FEAT] address SonarCloud — unused param, logging.exception, no-NULL error - PgExecutionDispatcher.dispatch: drop the unused `headers` param (PG carries fairness in the enqueue payload, not Celery headers; the routing dispatcher no longer forwards headers to the PG path) - enqueue failure handler: logger.error(exc_info=True) -> logger.exception - PgTaskResult.error: TextField(null=True) -> TextField(blank=True, default="") (no-NULL-text convention); store_result writes "" on completed; migration 0010 regenerated; result-backend test updated Co-Authored-By: Claude Opus 4.8 * UN-3603 [GATED-FEAT] address review — request-reply robustness, single-flag constant, tests, doc accuracy Toolkit review (UN-3603): - Critical: guard the success-path result store in the consumer — a store failure now logs + acks (avoids re-running the executor = LLM spend) instead of vt-redelivering; pre-execution drop branches (malformed/poison/unknown) and the task-raised branch store a definitive failure reply via a new guarded _fail_reply so the caller fails fast instead of blocking to its timeout - Med: shared PgTaskStatus StrEnum in unstract.core (writer + reader agree across the process boundary); guard ExecutionResult.from_dict so a malformed completed row can't break the never-raises contract; log timeout/failure branches - High: release the DB connection between dispatch polls (close_old_connections) + document dispatch must not run inside a transaction - Med: single source of truth for the flag key (pg_queue/flags.py PG_QUEUE_FLAG_KEY, imported by transport/ownership/executor_rpc; SCHEDULER_FLAG_KEY folded in) - Med: result-backend logs the swallowed rollback failure - doc/accuracy: soften the not-yet-wired retention-sweep claims; fix stale pg_executor_enabled -> pg_queue_enabled comments; tighten reply_key to NotRequired[str] - tests: PgExecutionDispatcher.dispatch (8 branches, DB-free) + consumer reply_key store/ack + drop-branch + store-failure-still-acks cases Co-Authored-By: Claude Opus 4.8 * UN-3603 [GATED-FEAT] address SonarCloud — logging.exception + drop comment-as-code - consumer.py: the two new request-reply error handlers use logger.exception() instead of logger.error(..., exc_info=True) - test_executor_rpc.py: reword the `# timeout=None` inline comment (Sonar read it as commented-out code) Co-Authored-By: Claude Opus 4.8 * UN-3603 [GATED-FEAT] address greptile — executor consumer VT/health tuning + guard env timeout parse greptile's load-bearing finding: worker-pg-executor inherited the consumer's 30s/60s vt / health-stale defaults, so an LLM task exceeding 30s would be re-claimed mid-run once the gate ramps — double execution + token double-spend, two runs racing the same reply_key. - docker-compose: set WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=3660 and HEALTH_STALE_SECONDS=3720 on worker-pg-executor (above the executor's hard EXECUTOR_TASK_TIME_LIMIT=3600), both overridable via env - executor_rpc.dispatch: guard the EXECUTOR_RESULT_TIMEOUT int parse so a misconfigured value can't raise out of dispatch() (never-raises) + test Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/pg_queue/executor_rpc.py | 259 ++++++++++++++++++ backend/pg_queue/flags.py | 12 + .../pg_queue/migrations/0010_pgtaskresult.py | 33 +++ backend/pg_queue/models.py | 44 +++ backend/pg_queue/producer.py | 9 + backend/pg_queue/tests/test_executor_rpc.py | 231 ++++++++++++++++ .../prompt_studio_helper.py | 16 +- backend/scheduler/ownership.py | 15 +- .../workflow_manager/workflow_v2/transport.py | 8 +- docker/docker-compose.yaml | 44 +++ .../core/src/unstract/core/data_models.py | 23 +- workers/queue_backend/pg_queue/consumer.py | 92 ++++++- .../queue_backend/pg_queue/result_backend.py | 195 +++++++++++++ workers/run-worker.sh | 7 + workers/tests/test_pg_queue_consumer.py | 59 +++- workers/tests/test_pg_result_backend.py | 103 +++++++ 16 files changed, 1130 insertions(+), 20 deletions(-) create mode 100644 backend/pg_queue/executor_rpc.py create mode 100644 backend/pg_queue/flags.py create mode 100644 backend/pg_queue/migrations/0010_pgtaskresult.py create mode 100644 backend/pg_queue/tests/test_executor_rpc.py create mode 100644 workers/queue_backend/pg_queue/result_backend.py create mode 100644 workers/tests/test_pg_result_backend.py diff --git a/backend/pg_queue/executor_rpc.py b/backend/pg_queue/executor_rpc.py new file mode 100644 index 0000000000..35615671b6 --- /dev/null +++ b/backend/pg_queue/executor_rpc.py @@ -0,0 +1,259 @@ +"""Executor-RPC transport routing for the PG path (Phase 9). + +The executor "RPC" is a synchronous request-reply: a caller (prompt-studio here) +sends an ``ExecutionContext`` to the executor worker and blocks for the +``ExecutionResult``. The legacy transport is Celery — the SDK +``ExecutionDispatcher`` (``send_task`` + ``AsyncResult.get``). This module adds a +**parallel** Postgres transport that leaves Celery and the SDK completely +untouched (no SDK edit, no change to the ``execute_extraction`` task or the +Celery executor worker): + +- :class:`PgExecutionDispatcher` enqueues ``execute_extraction`` onto the PG queue + with a unique ``reply_key`` and polls ``pg_task_result`` for the reply — same + ``.dispatch()`` contract as the SDK dispatcher (never raises; failure/timeout → + ``ExecutionResult.failure``). +- :func:`resolve_executor_transport` is the gate: master + ``PG_QUEUE_TRANSPORT_ENABLED`` then the **single** Flipt flag + ``pg_queue_enabled`` — the same flag the execution path uses, so one + flip turns the whole PG-queue feature on/off (no per-subsystem flags to + maintain). Fails closed to Celery. +- :class:`RoutingExecutionDispatcher` is what callers get from + ``PromptStudioHelper._get_dispatcher()``: ``dispatch()`` picks PG-vs-Celery + **per call** (read at dispatch time → flipping the flag is an instant, + no-redeploy rollout/rollback); ``dispatch_async`` / ``dispatch_with_callback`` + always delegate to Celery (the callback path is a later slice). + +Zero-regression: gate off ⇒ every method delegates to the unchanged Celery +``ExecutionDispatcher`` and no ``pg_task_result`` row is created. +""" + +from __future__ import annotations + +import logging +import os +import time +import uuid +from typing import TYPE_CHECKING, Any + +from django.conf import settings +from django.db import close_old_connections + +from pg_queue.flags import PG_QUEUE_FLAG_KEY +from pg_queue.models import PgTaskResult +from pg_queue.producer import enqueue_task +from unstract.core.data_models import PgTaskStatus +from unstract.flags.feature_flag import check_feature_flag_status +from unstract.sdk1.execution.dispatcher import ExecutionDispatcher +from unstract.sdk1.execution.result import ExecutionResult + +if TYPE_CHECKING: + from unstract.sdk1.execution.context import ExecutionContext + +logger = logging.getLogger(__name__) + +# Gating reads the single shared PG-queue flag (pg_queue.flags.PG_QUEUE_FLAG_KEY, +# imported above) — the same key execution and the scheduler use. +_EXECUTE_TASK = "execute_extraction" +# Mirror the SDK's queue-per-executor convention so the PG executor queue name +# matches the Celery one (the queue routes by the row's queue_name column). +_QUEUE_PREFIX = "celery_executor_" +# Caller-side wait default — mirrors the SDK dispatcher (EXECUTOR_RESULT_TIMEOUT +# env, else 3600s) so a PG-routed caller waits exactly as long as a Celery one. +_DEFAULT_TIMEOUT_ENV = "EXECUTOR_RESULT_TIMEOUT" +_DEFAULT_TIMEOUT = 3600 +_POLL_INITIAL_SECONDS = 0.2 +_POLL_MAX_SECONDS = 2.0 + + +def resolve_executor_transport(context: ExecutionContext) -> bool: + """True → route this executor dispatch over PG; False → Celery (default). + + Mirrors ``resolve_transport``: master-gated by ``PG_QUEUE_TRANSPORT_ENABLED``, + then the **single** ``pg_queue_enabled`` Flipt flag (shared across + the whole PG-queue feature), bucketed per org. **Fails closed to Celery** on a + closed gate, a blind Flipt, or any error — so the executor never silently + loses its transport. + """ + if not settings.PG_QUEUE_TRANSPORT_ENABLED: + return False + if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": + logger.warning( + "resolve_executor_transport: gate ON but FLIPT_SERVICE_AVAILABLE != " + "true (Flipt blind); using Celery" + ) + return False + org = getattr(context, "organization_id", None) + # %-bucket keyed on org (prompt-studio is org-scoped); fall back to run_id so + # a context without an org still resolves deterministically. + entity_id = str(org or getattr(context, "run_id", "") or "default") + flag_context = {"executor_name": str(context.executor_name)} + if org: + flag_context["organization_id"] = str(org) + try: + enabled = check_feature_flag_status( + flag_key=PG_QUEUE_FLAG_KEY, entity_id=entity_id, context=flag_context + ) + except Exception: + logger.warning( + "resolve_executor_transport: Flipt check failed; using Celery", + exc_info=True, + ) + return False + return bool(enabled) + + +class PgExecutionDispatcher: + """PG request-reply executor dispatch — drop-in for ``ExecutionDispatcher.dispatch``. + + Enqueues ``execute_extraction`` with a unique ``reply_key`` and blocks on + ``pg_task_result`` until the executor consumer records the result or the + timeout elapses. Honours the same contract as the SDK dispatcher: it never + raises and converts a timeout/failure into ``ExecutionResult.failure`` so + callers can branch on ``result.success`` identically on either transport. + """ + + def dispatch( + self, + context: ExecutionContext, + timeout: int | None = None, + ) -> ExecutionResult: + if timeout is None: + # Guard the env parse so a misconfigured EXECUTOR_RESULT_TIMEOUT can't + # raise out of dispatch() (the never-raises contract). + try: + timeout = int(os.environ.get(_DEFAULT_TIMEOUT_ENV, _DEFAULT_TIMEOUT)) + except (TypeError, ValueError): + timeout = _DEFAULT_TIMEOUT + reply_key = str(uuid.uuid4()) + queue = f"{_QUEUE_PREFIX}{context.executor_name}" + org = getattr(context, "organization_id", "") or "" + try: + enqueue_task( + task_name=_EXECUTE_TASK, + queue=queue, + args=[context.to_dict()], + org_id=str(org), + reply_key=reply_key, + ) + except Exception as exc: + logger.exception( + "PG executor dispatch: enqueue failed (executor=%s run_id=%s)", + context.executor_name, + context.run_id, + ) + return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}") + logger.info( + "PG executor dispatch: enqueued reply_key=%s queue=%s run_id=%s " + "timeout=%ss; waiting for result...", + reply_key, + queue, + context.run_id, + timeout, + ) + row = self._wait_for_result(reply_key, timeout) + if row is None: + logger.warning( + "PG executor dispatch: TIMEOUT after %ss (reply_key=%s run_id=%s) — " + "the executor task may still be running", + timeout, + reply_key, + context.run_id, + ) + return ExecutionResult.failure( + error=f"TimeoutError: executor reply not received within {timeout}s" + ) + if row.status == PgTaskStatus.COMPLETED.value and row.result is not None: + try: + return ExecutionResult.from_dict(row.result) + except Exception: + # Honour the never-raises contract: a malformed completed row + # becomes a failure result, not a 500 to the caller. + logger.exception( + "PG executor dispatch: malformed completed result " + "(reply_key=%s run_id=%s)", + reply_key, + context.run_id, + ) + return ExecutionResult.failure( + error=f"Malformed executor result for reply_key {reply_key}" + ) + logger.warning( + "PG executor dispatch: executor reported failure (reply_key=%s " + "run_id=%s): %s", + reply_key, + context.run_id, + row.error or "(no error)", + ) + return ExecutionResult.failure(error=row.error or "executor task failed") + + @staticmethod + def _wait_for_result(reply_key: str, timeout: float) -> PgTaskResult | None: + """Poll ``pg_task_result`` until the row appears or *timeout* elapses. + + Poll-based with capped backoff (PgBouncer-safe; no LISTEN/NOTIFY). The DB + connection is released between polls (``close_old_connections``) so a + long-running RPC does not pin a backend connection for its whole duration + and exhaust the pool. Each poll is its own autocommit query, so a row + committed by the executor consumer becomes visible — **dispatch must NOT + be called inside an open transaction** (``transaction.atomic`` / + ``ATOMIC_REQUESTS`` would pin one snapshot and never see the new row). + """ + deadline = time.monotonic() + timeout + delay = _POLL_INITIAL_SECONDS + while True: + row = PgTaskResult.objects.filter(pk=reply_key).first() + if row is not None: + return row + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + # Don't hold the connection idle through the sleep. + close_old_connections() + time.sleep(min(delay, remaining)) + delay = min(delay * 2, _POLL_MAX_SECONDS) + + +class RoutingExecutionDispatcher: + """Gate-routed executor dispatcher returned by ``_get_dispatcher()``. + + ``dispatch()`` chooses PG vs Celery per call (instant rollout/rollback); + ``dispatch_async`` / ``dispatch_with_callback`` always delegate to Celery — + the async/callback path stays on Celery until a later continuation slice. + Duck-typed against the SDK ``ExecutionDispatcher`` so call sites are unchanged. + """ + + def __init__(self, celery_app: object | None = None) -> None: + self._celery = ExecutionDispatcher(celery_app=celery_app) + self._pg = PgExecutionDispatcher() + + def dispatch( + self, + context: ExecutionContext, + timeout: int | None = None, + headers: dict[str, Any] | None = None, + ) -> ExecutionResult: + if resolve_executor_transport(context): + logger.info( + "Executor RPC → PG transport (executor=%s run_id=%s)", + context.executor_name, + context.run_id, + ) + # PG carries fairness via the enqueue payload, not Celery headers, so + # the headers (fairness key) are intentionally not forwarded here. + return self._pg.dispatch(context, timeout=timeout) + return self._celery.dispatch(context, timeout=timeout, headers=headers) + + def dispatch_async( + self, context: ExecutionContext, headers: dict[str, Any] | None = None + ) -> str: + return self._celery.dispatch_async(context, headers=headers) + + def dispatch_with_callback(self, context: ExecutionContext, **kwargs: Any) -> Any: + return self._celery.dispatch_with_callback(context, **kwargs) + + +def get_executor_dispatcher( + celery_app: object | None = None, +) -> RoutingExecutionDispatcher: + """Factory: the gate-routed executor dispatcher (PG when enabled, else Celery).""" + return RoutingExecutionDispatcher(celery_app=celery_app) diff --git a/backend/pg_queue/flags.py b/backend/pg_queue/flags.py new file mode 100644 index 0000000000..f2d56d8b73 --- /dev/null +++ b/backend/pg_queue/flags.py @@ -0,0 +1,12 @@ +"""Single source of truth for the PG-queue rollout flag key. + +One Flipt flag gates the whole PG-queue feature — execution +(``workflow_v2/transport.py``), scheduler (``scheduler/ownership.py``), and +executor (``pg_queue/executor_rpc.py``) all read this one key. Kept in a neutral +leaf module so the three resolvers import a single constant instead of +duplicating the literal (a grep on ``PG_QUEUE_FLAG_KEY`` finds every use), making +"one flag" a structural guarantee. ``PG_QUEUE_TRANSPORT_ENABLED`` (env) remains +the master kill-switch consulted before this flag. +""" + +PG_QUEUE_FLAG_KEY = "pg_queue_enabled" diff --git a/backend/pg_queue/migrations/0010_pgtaskresult.py b/backend/pg_queue/migrations/0010_pgtaskresult.py new file mode 100644 index 0000000000..c66f066db6 --- /dev/null +++ b/backend/pg_queue/migrations/0010_pgtaskresult.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.1 on 2026-06-19 17:06 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "pg_queue", + "0009_remove_pgperiodicschedule_pg_periodic_schedule_due_idx_and_more", + ), + ] + + operations = [ + migrations.CreateModel( + name="PgTaskResult", + fields=[ + ("task_id", models.TextField(primary_key=True, serialize=False)), + ("status", models.TextField()), + ("result", models.JSONField(blank=True, null=True)), + ("error", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "db_table": "pg_task_result", + "indexes": [ + models.Index(fields=["expires_at"], name="pg_task_result_expires_idx") + ], + }, + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index c079c9f498..e413bd3712 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -285,3 +285,47 @@ class Meta: name="pg_periodic_schedule_due_idx", ), ] + + +class PgTaskResult(models.Model): + """Request-reply result store for the executor RPC on PG (Phase 9). + + Replaces Celery's ``AsyncResult`` / result backend for the *blocking* + executor dispatch when it rides the PG transport. A row appears ONLY when the + executor task finishes: the PG executor consumer (``worker-pg-executor``) + writes the returned ``ExecutionResult`` (``status="completed"``) or the error + text if the task raised (``status="failed"``), keyed by the caller-chosen + ``task_id`` (the reply key). The blocking caller polls this table until the + row appears or its timeout elapses; a non-blocking poll just checks presence. + + The absence of a row means "not done yet" — there is deliberately no + ``pending`` state to maintain. A separate, droppable table that never touches + ``WorkflowExecution`` — same posture as ``PgBarrierState`` / ``PgBatchDedup``, + extension-free (UN-3533). ``expires_at`` is written for a **future** retention + sweep (``DELETE … WHERE expires_at <= now()``); that sweep is **not wired yet** + — until it lands the table grows with each RPC (acceptable while the feature is + gated off; tracked as follow-up before the gate ramps to 100%). + """ + + # Caller-chosen reply key (opaque text, mirrors a Celery task id's role): the + # caller waits on it, the consumer stores the result under it. + task_id = models.TextField(primary_key=True) + # "completed" = task returned (``result`` holds ExecutionResult.to_dict()); + # "failed" = task raised (``error`` holds the message). + status = models.TextField() + result = models.JSONField(null=True, blank=True) + # No-NULL text convention: "" on a completed row (no error), the message on a + # failed row — avoids a string column having two empty states (NULL vs ""). + error = models.TextField(blank=True, default="") + created_at = models.DateTimeField(default=timezone.now) + # Retention horizon for the future sweep (see class docstring). The writer + # always sets it; nullable only so the column itself imposes no NOT NULL. + expires_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "pg_task_result" + indexes = [ + # Index for the future retention sweep (DELETE ... WHERE + # expires_at <= now()); no sweeper reads it yet (see class docstring). + models.Index(fields=["expires_at"], name="pg_task_result_expires_idx"), + ] diff --git a/backend/pg_queue/producer.py b/backend/pg_queue/producer.py index c4e9be5cf1..f258b8a89a 100644 --- a/backend/pg_queue/producer.py +++ b/backend/pg_queue/producer.py @@ -62,6 +62,7 @@ def enqueue_task( org_id: str = "", priority: int = DEFAULT_PRIORITY, fairness: FairnessPayload | None = None, + reply_key: str | None = None, ) -> int: """Enqueue a task onto the PG queue; returns the new ``msg_id``. @@ -69,6 +70,10 @@ def enqueue_task( consumer can decode and run it. A PG enqueue failure propagates — the caller decides; for the orchestrator there is no silent Celery fallback (that would hide the failure or risk a double-dispatch). + + ``reply_key`` marks a **request-reply** dispatch (the executor RPC on PG): + the executor consumer writes the task's result/error to ``pg_task_result`` + under it for the blocking caller to poll. Omitted = fire-and-forget. """ if not FAIRNESS_MIN_PRIORITY <= priority <= FAIRNESS_MAX_PRIORITY: raise ValueError( @@ -83,6 +88,10 @@ def enqueue_task( "queue": pg_queue, "fairness": fairness, } + # Only set for request-reply dispatches — keeps fire-and-forget rows + # byte-identical to before this field existed. + if reply_key is not None: + message["reply_key"] = reply_key # Mirror the worker _enqueue_pg path: log the failure with breadcrumbs before # it propagates, so a DB/constraint/serialization error isn't mislabeled by # the caller's broad handler. diff --git a/backend/pg_queue/tests/test_executor_rpc.py b/backend/pg_queue/tests/test_executor_rpc.py new file mode 100644 index 0000000000..b91f923377 --- /dev/null +++ b/backend/pg_queue/tests/test_executor_rpc.py @@ -0,0 +1,231 @@ +"""Tests for the executor-RPC transport routing (Phase 9). + +DB-free: settings / Flipt / the sub-dispatchers are mocked. Pins the gate's +fail-closed matrix and — the load-bearing zero-regression property — that with +the gate off ``RoutingExecutionDispatcher`` delegates EVERY mode to the unchanged +Celery ``ExecutionDispatcher`` and never touches the PG path. +""" + +from unittest.mock import MagicMock, patch + +from pg_queue.executor_rpc import ( + PgExecutionDispatcher, + RoutingExecutionDispatcher, + resolve_executor_transport, +) + +_MOD = "pg_queue.executor_rpc" + + +def _completed(result: dict) -> MagicMock: + return MagicMock(status="completed", result=result, error="") + + +def _ok_result() -> dict: + return {"success": True, "data": {"x": 1}, "metadata": {}, "error": None} + + +class TestPgExecutionDispatcherDispatch: + """The load-bearing contract: never raises; timeout/failure → failure result. + + DB-free — ``enqueue_task`` and ``_wait_for_result`` are mocked. + """ + + @staticmethod + def _ctx() -> MagicMock: + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "r" + c.organization_id = "o" + c.to_dict.return_value = {"run_id": "r"} + return c + + def test_enqueue_failure_returns_failure_not_raise(self): + with patch(f"{_MOD}.enqueue_task", side_effect=RuntimeError("db down")): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + assert "RuntimeError" in res.error + + def test_timeout_returns_failure(self): + with ( + patch(f"{_MOD}.enqueue_task"), + patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=None), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=3) + assert res.success is False + assert "within 3s" in res.error + + def test_completed_row_returns_result(self): + with ( + patch(f"{_MOD}.enqueue_task"), + patch.object( + PgExecutionDispatcher, "_wait_for_result", + return_value=_completed(_ok_result()), + ), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is True + + def test_failed_row_returns_error(self): + row = MagicMock(status="failed", result=None, error="boom") + with ( + patch(f"{_MOD}.enqueue_task"), + patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + assert res.error == "boom" + + def test_failed_row_empty_error_falls_back(self): + row = MagicMock(status="failed", result=None, error="") + with ( + patch(f"{_MOD}.enqueue_task"), + patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + assert "executor task failed" in res.error + + def test_completed_but_result_none_is_failure(self): + row = MagicMock(status="completed", result=None, error="") + with ( + patch(f"{_MOD}.enqueue_task"), + patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + + def test_malformed_completed_row_is_failure_not_raise(self): + with ( + patch(f"{_MOD}.enqueue_task"), + patch.object( + PgExecutionDispatcher, "_wait_for_result", + return_value=_completed({"bad": "shape"}), + ), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + assert "Malformed" in res.error + + def test_timeout_none_reads_env_then_default(self, monkeypatch): + monkeypatch.setenv("EXECUTOR_RESULT_TIMEOUT", "42") + seen = {} + + def fake_wait(reply_key, timeout): + seen["timeout"] = timeout + return None + + with ( + patch(f"{_MOD}.enqueue_task"), + patch.object( + PgExecutionDispatcher, "_wait_for_result", side_effect=fake_wait + ), + ): + # No explicit timeout arg → falls back to the env/default. + PgExecutionDispatcher().dispatch(self._ctx()) + assert seen["timeout"] == 42 + + def test_timeout_none_bad_env_falls_back_to_default(self, monkeypatch): + monkeypatch.setenv("EXECUTOR_RESULT_TIMEOUT", "not-an-int") + seen = {} + + def fake_wait(reply_key, timeout): + seen["timeout"] = timeout + return None + + with ( + patch(f"{_MOD}.enqueue_task"), + patch.object( + PgExecutionDispatcher, "_wait_for_result", side_effect=fake_wait + ), + ): + PgExecutionDispatcher().dispatch(self._ctx()) # must not raise + assert seen["timeout"] == 3600 # _DEFAULT_TIMEOUT + + +def _ctx(org: str | None = "org1") -> MagicMock: + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "run-1" + c.organization_id = org + return c + + +def _gate(on: bool): + s = MagicMock() + s.PG_QUEUE_TRANSPORT_ENABLED = on + return patch(f"{_MOD}.settings", s) + + +class TestResolveExecutorTransport: + def test_master_gate_off_is_celery(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with _gate(False), patch(f"{_MOD}.check_feature_flag_status") as flag: + assert resolve_executor_transport(_ctx()) is False + flag.assert_not_called() # gate off → Flipt never consulted + + def test_flipt_unavailable_is_celery(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") + with _gate(True), patch(f"{_MOD}.check_feature_flag_status") as flag: + assert resolve_executor_transport(_ctx()) is False + flag.assert_not_called() + + def test_flag_true_is_pg_keyed_on_org(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with _gate(True), patch( + f"{_MOD}.check_feature_flag_status", return_value=True + ) as flag: + assert resolve_executor_transport(_ctx("orgX")) is True + assert flag.call_args.kwargs["entity_id"] == "orgX" + # The single shared PG-queue flag (not a per-subsystem flag). + assert flag.call_args.kwargs["flag_key"] == "pg_queue_enabled" + + def test_flag_false_is_celery(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with _gate(True), patch( + f"{_MOD}.check_feature_flag_status", return_value=False + ): + assert resolve_executor_transport(_ctx()) is False + + def test_flipt_error_fails_closed_to_celery(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with _gate(True), patch( + f"{_MOD}.check_feature_flag_status", side_effect=RuntimeError("down") + ): + assert resolve_executor_transport(_ctx()) is False + + +class TestRoutingZeroRegression: + @staticmethod + def _build(): + # Patch both sub-dispatchers at construction; the instances are captured + # in __init__ so they remain mocked after the context exits. + with ( + patch(f"{_MOD}.ExecutionDispatcher") as celery_cls, + patch(f"{_MOD}.PgExecutionDispatcher") as pg_cls, + ): + dispatcher = RoutingExecutionDispatcher(celery_app="app") + return dispatcher, celery_cls.return_value, pg_cls.return_value + + def test_gate_off_dispatch_uses_celery_only(self): + dispatcher, celery, pg = self._build() + with patch(f"{_MOD}.resolve_executor_transport", return_value=False): + dispatcher.dispatch(_ctx()) + celery.dispatch.assert_called_once() + pg.dispatch.assert_not_called() # the zero-regression guarantee + + def test_gate_on_dispatch_uses_pg(self): + dispatcher, celery, pg = self._build() + with patch(f"{_MOD}.resolve_executor_transport", return_value=True): + dispatcher.dispatch(_ctx()) + pg.dispatch.assert_called_once() + celery.dispatch.assert_not_called() + + def test_async_and_callback_always_celery(self): + """The callback/async path stays on Celery regardless of the gate (a later slice).""" + dispatcher, celery, pg = self._build() + dispatcher.dispatch_async(_ctx()) + dispatcher.dispatch_with_callback(_ctx(), on_success=None) + celery.dispatch_async.assert_called_once() + celery.dispatch_with_callback.assert_called_once() + pg.dispatch.assert_not_called() diff --git a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py index af6ce3a34b..17ae59ba5a 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py +++ b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py @@ -73,7 +73,6 @@ from unstract.sdk1.constants import LogLevel from unstract.sdk1.exceptions import IndexingError, SdkError from unstract.sdk1.execution.context import ExecutionContext -from unstract.sdk1.execution.dispatcher import ExecutionDispatcher from unstract.sdk1.file_storage.constants import StorageType from unstract.sdk1.file_storage.env_helper import EnvHelper from unstract.sdk1.utils.indexing import IndexingUtils @@ -288,9 +287,18 @@ def _publish_log( ) @staticmethod - def _get_dispatcher() -> ExecutionDispatcher: - """Get an ExecutionDispatcher for the executor worker.""" - return ExecutionDispatcher(celery_app=celery_app) + def _get_dispatcher(): + """Executor dispatcher for the executor worker. + + Gate-routed: when ``pg_queue_enabled`` is on the blocking + ``dispatch()`` rides the PG request-reply transport; otherwise — and for + all async/callback dispatches — it is the unchanged Celery + ``ExecutionDispatcher``. The decision is read per dispatch, so flipping + the flag is an instant, no-redeploy rollout/rollback. + """ + from pg_queue.executor_rpc import get_executor_dispatcher + + return get_executor_dispatcher(celery_app=celery_app) @staticmethod def _get_platform_api_key(org_id: str) -> str: diff --git a/backend/scheduler/ownership.py b/backend/scheduler/ownership.py index 8331cf1c2a..f5f3684419 100644 --- a/backend/scheduler/ownership.py +++ b/backend/scheduler/ownership.py @@ -12,8 +12,8 @@ ``PeriodicTask`` disabled, so the two can't both fire. Inert by default: ``resolve_schedule_owner`` fails closed to Beat -(``pg_owned=False``) until ops turns the master gate on AND ramps the -``pg_scheduler_enabled`` Flipt flag — so reconciling on every schedule edit is a +(``pg_owned=False``) until ops turns the master gate on AND ramps the single +``pg_queue_enabled`` Flipt flag — so reconciling on every schedule edit is a no-op (everything stays Beat-owned) until the rollout starts. """ @@ -26,22 +26,23 @@ from django.db import transaction from django.utils import timezone from django_celery_beat.models import PeriodicTask +from pg_queue.flags import PG_QUEUE_FLAG_KEY from pg_queue.models import PgPeriodicSchedule from unstract.flags.feature_flag import check_feature_flag_status logger = logging.getLogger(__name__) -# Independent of the execution-transport flag (pg_queue_execution_enabled) so -# scheduling and execution ramp separately. %-rollout keyed on pipeline_id. -SCHEDULER_FLAG_KEY = "pg_scheduler_enabled" +# Gating uses the single shared PG-queue flag (pg_queue.flags.PG_QUEUE_FLAG_KEY, +# imported above) — one flip gates execution + scheduler + executor. The scheduler +# buckets the %-rollout on pipeline_id (each subsystem keys on its own entity). def resolve_schedule_owner(pipeline_id: str, organization_id: str | None) -> bool: """True → the PG scheduler owns this schedule; False → Celery Beat does. Mirrors ``resolve_transport``: master-gated by ``PG_QUEUE_TRANSPORT_ENABLED`` - (shared PG kill-switch), then the ``pg_scheduler_enabled`` Flipt flag, keyed + (shared PG kill-switch), then the single ``pg_queue_enabled`` Flipt flag, keyed on ``pipeline_id`` for a stable percentage bucket. **Fails closed to Beat** on a closed gate, a blind Flipt, or any error — so a schedule never silently loses its firer. @@ -66,7 +67,7 @@ def resolve_schedule_owner(pipeline_id: str, organization_id: str | None) -> boo context["organization_id"] = str(organization_id) try: owned = check_feature_flag_status( - flag_key=SCHEDULER_FLAG_KEY, entity_id=str(pipeline_id), context=context + flag_key=PG_QUEUE_FLAG_KEY, entity_id=str(pipeline_id), context=context ) except Exception: # Expected, recoverable (fail-closed to Beat) and runs on every schedule diff --git a/backend/workflow_manager/workflow_v2/transport.py b/backend/workflow_manager/workflow_v2/transport.py index 9b0796fd06..337e3dfe1d 100644 --- a/backend/workflow_manager/workflow_v2/transport.py +++ b/backend/workflow_manager/workflow_v2/transport.py @@ -10,7 +10,7 @@ PR 3 (this change) replaces PR 1's hardwired Celery with a Flipt evaluation: - master-gate (env) → Flipt boolean (``pg_queue_execution_enabled``) → transport + master-gate (env) → Flipt boolean (``pg_queue_enabled``) → transport Routing onto PG needs **all three** of: the env master-gate on, Flipt reachable (``FLIPT_SERVICE_AVAILABLE=true``), and the flag enabled for this execution. @@ -36,6 +36,7 @@ from typing import TYPE_CHECKING from django.conf import settings +from pg_queue.flags import PG_QUEUE_FLAG_KEY from unstract.core.data_models import WorkflowTransport from unstract.flags.feature_flag import check_feature_flag_status @@ -45,8 +46,9 @@ logger = logging.getLogger(__name__) -# The fixed Flipt flag contract (9e-design §2): Boolean, default false. -PG_QUEUE_FLAG_KEY = "pg_queue_execution_enabled" +# The single PG-queue rollout flag (Boolean, default false) is defined once in +# pg_queue.flags and imported above; execution, scheduler, and executor all read +# that one key. Re-exported here for callers/tests that import it from this module. def resolve_transport( diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 70579c657d..f58f5a9761 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -709,6 +709,50 @@ services: profiles: - pg-queue + # Executor RPC over PG — runs execute_extraction as a request-reply: claims + # work from Postgres and writes the ExecutionResult to pg_task_result for the + # blocking caller. Same heavy executor runtime as worker-executor-v2 (tool + # execution → platform-service / adapters / file storage), but broker-free: it + # is the terminal of the RPC and dispatches nothing onward to Celery. Dark + # until the backend pg_queue_enabled gate is flipped. + worker-pg-executor: + image: unstract/worker-unified:${VERSION} + container_name: unstract-worker-pg-executor + restart: unless-stopped + command: ["pg-queue-consumer"] + ports: + - "8099:8090" + env_file: + - ../workers/.env + - ./essentials.env + depends_on: + - db + - redis + - platform-service + environment: + - ENVIRONMENT=development + - APPLICATION_NAME=unstract-worker-pg-executor + - WORKER_BARRIER_BACKEND=pg + - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=executor + - WORKER_PG_QUEUE_CONSUMER_QUEUE=celery_executor_legacy,celery_executor_agentic,celery_executor_agentic_table + - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + # The executor runs LLM inference — far longer than the consumer's 30s/60s + # defaults. Set the visibility timeout AND the liveness stale-threshold + # ABOVE the executor's hard per-task limit (EXECUTOR_TASK_TIME_LIMIT=3600s), + # else a long task is re-claimed mid-run (double execution / token + # double-spend, two runs racing the same reply_key) or killed by the probe. + - WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=${WORKER_PG_EXECUTOR_VT_SECONDS:-3660} + - WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS=${WORKER_PG_EXECUTOR_HEALTH_STALE_SECONDS:-3720} + - CELERY_QUEUES_EXECUTOR=${CELERY_QUEUES_EXECUTOR:-celery_executor_legacy,celery_executor_agentic,celery_executor_agentic_table} + labels: + - traefik.enable=false + volumes: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + - prompt_studio_data:/app/prompt-studio-data + profiles: + - pg-queue + # Reaper / orchestrator — leader-elected loop. Run exactly ONE instance (it # elects a single leader via pg_orchestrator_lock; extra replicas idle as # standby). Besides barrier-orphan recovery it runs the PG scheduler tick diff --git a/unstract/core/src/unstract/core/data_models.py b/unstract/core/src/unstract/core/data_models.py index 94e6148305..cfcbd027e1 100644 --- a/unstract/core/src/unstract/core/data_models.py +++ b/unstract/core/src/unstract/core/data_models.py @@ -10,7 +10,7 @@ from dataclasses import asdict, dataclass, field from datetime import UTC, datetime from enum import Enum, StrEnum -from typing import Any, Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict logger = logging.getLogger(__name__) @@ -315,6 +315,14 @@ class TaskPayload(TypedDict): *column* of the row, not by this field. Producers record the logical queue here for debugging (the backend producer always sets it; the workers' ``to_payload`` may leave it ``None``). + + ``reply_key`` is set only for **request-reply** dispatches (the executor RPC + on PG): the caller generates a unique key, waits on it, and the + executor consumer writes the task's result/error to ``pg_task_result`` under + it. "request-reply" is exactly "key present" — it is ``NotRequired[str]`` (not + ``str | None``) so there is one meaning per representation (absent = fire-and- + forget; present = a real key). It is deliberately a dedicated key, not + ``request_id`` (a caller-supplied tracing id that may not be unique). """ task_name: str @@ -322,6 +330,19 @@ class TaskPayload(TypedDict): kwargs: dict[str, Any] queue: str | None fairness: FairnessPayload | None + reply_key: NotRequired[str] + + +class PgTaskStatus(str, Enum): + """Status vocabulary for a ``pg_task_result`` row — the executor-RPC + request-reply contract (Phase 9). Shared in ``unstract.core`` so the writer + (workers ``PgResultBackend``) and reader (backend executor-RPC dispatcher), + which live in separate trees with no shared import, agree on one source of + truth rather than matching bare literals by eye across the process boundary. + """ + + COMPLETED = "completed" # task returned; ``result`` holds ExecutionResult dict + FAILED = "failed" # task raised; ``error`` holds the message class FileListingResult: diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index cdf7a20836..50af3efea7 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -32,6 +32,7 @@ from ..fairness import FAIRNESS_HEADER_NAME from .client import PgQueueClient from .liveness import LivenessServer as _BaseLivenessServer +from .result_backend import PgResultBackend if TYPE_CHECKING: from celery import Celery @@ -116,6 +117,10 @@ def __init__( self.backoff_max = backoff_max self.max_attempts = max_attempts self._running = False + # Request-reply (executor RPC) result store — lazily created the first + # time a message carries a ``reply_key``; fire-and-forget consumers + # (orchestrator/fileproc/callback/scheduler) never instantiate it. + self._result_backend: PgResultBackend | None = None # Heartbeat for the liveness probe: monotonic timestamp of the most # recent poll attempt. Seeded at construction so a just-started consumer # reads healthy. Updated at the TOP of poll_once, so a loop wedged on a @@ -153,6 +158,12 @@ def poll_once(self) -> int: def _handle(self, message: QueueMessage) -> None: payload = message.message task_name = payload.get("task_name") + # Request-reply (executor RPC): a unique key the dispatching caller is + # blocking on. Read up front so the drop branches below can store a + # definitive failure reply (the caller fails fast instead of blocking to + # its full timeout). Present → store the outcome + ack after one attempt; + # absent → fire-and-forget (the existing leaf/pipeline path). + reply_key = payload.get("reply_key") # Malformed / foreign payload: no task name → can't run; drop with a # log that points at the payload, not at task registration. @@ -163,6 +174,7 @@ def _handle(self, message: QueueMessage) -> None: message.msg_id, payload, ) + self._fail_reply(reply_key, "malformed message: missing task_name") self._client.delete(message.msg_id) return @@ -179,6 +191,9 @@ def _handle(self, message: QueueMessage) -> None: message.read_ct, payload, ) + self._fail_reply( + reply_key, f"task {task_name} exceeded max_attempts={self.max_attempts}" + ) self._client.delete(message.msg_id) return @@ -190,6 +205,7 @@ def _handle(self, message: QueueMessage) -> None: task_name, message.msg_id, ) + self._fail_reply(reply_key, f"unknown task {task_name}") self._client.delete(message.msg_id) return @@ -198,15 +214,33 @@ def _handle(self, message: QueueMessage) -> None: # header so a PG-routed run mirrors the Celery dispatch path. fairness = payload.get("fairness") headers = {FAIRNESS_HEADER_NAME: fairness} if fairness else None - task.apply( + eager = task.apply( args=payload.get("args") or [], kwargs=payload.get("kwargs") or {}, headers=headers, throw=True, ) - except Exception: - # Leave the row: its vt expires and it is redelivered (bounded by - # max_attempts above). + except Exception as exc: + if reply_key: + # Record the failure so the waiting caller gets a definitive + # result instead of blocking to its timeout, then ACK. We do NOT + # vt-redeliver: a redelivery would race a result the caller may + # already have consumed, and re-running the executor is costly + # (LLM spend). The executor task's own autoretry covers transient + # errors within this attempt; the caller re-dispatches with a + # fresh reply_key to retry the whole RPC. + self._fail_reply(reply_key, f"{type(exc).__name__}: {exc}") + self._client.delete(message.msg_id) # ack + logger.exception( + "PG-queue consumer: request-reply task %r (msg_id=%s) failed " + "— stored error + acked (reply_key=%s)", + task_name, + message.msg_id, + reply_key, + ) + return + # Fire-and-forget: leave the row — its vt expires and it is + # redelivered (bounded by max_attempts above). logger.exception( "PG-queue consumer: task %r (msg_id=%s, read_ct=%s) failed — " "leaving for vt-expiry redelivery", @@ -216,6 +250,24 @@ def _handle(self, message: QueueMessage) -> None: ) return + if reply_key: + # Persist the result for the waiting caller before ack. Guarded: a + # store failure must NOT leave the message for vt-redelivery — that + # re-runs the executor (real LLM spend) and blocks the caller to its + # full timeout. Log loudly and ack anyway; the caller degrades to a + # timeout (rare), but we never double-spend. + try: + self._store_reply(reply_key, result=eager.result) + except Exception: + logger.exception( + "PG-queue consumer: FAILED to store request-reply result " + "(task=%r msg_id=%s reply_key=%s) — acking anyway to avoid an " + "expensive re-run; caller will time out", + task_name, + message.msg_id, + reply_key, + ) + if not self._client.delete(message.msg_id): # ack logger.warning( "PG-queue consumer: ack found no row for task %r (msg_id=%s) — " @@ -224,6 +276,38 @@ def _handle(self, message: QueueMessage) -> None: message.msg_id, ) + def _store_reply( + self, reply_key: str, *, result: dict | None = None, error: str | None = None + ) -> None: + """Persist a request-reply task's outcome to ``pg_task_result``. + + Lazily opens the result-backend connection on first use (so only the + executor consumer pays for it). ``store_result`` is idempotent + (first-write-wins), so a redelivery before the original ack is harmless. + """ + if self._result_backend is None: + self._result_backend = PgResultBackend() + self._result_backend.store_result(reply_key, result=result, error=error) + + def _fail_reply(self, reply_key: str | None, error: str) -> None: + """Best-effort failure reply for a request-reply message that can't run + or whose run raised (drop / poison / unknown-task / exception). + + No-op without a ``reply_key``; never raises — a store failure here must + not wedge the drop/ack path (the message is acked regardless), it just + means the caller degrades to a timeout instead of a fast definitive error. + """ + if not reply_key: + return + try: + self._store_reply(reply_key, error=error) + except Exception: + logger.exception( + "PG-queue consumer: failed to store failure reply (reply_key=%s): %s", + reply_key, + error, + ) + def _registered_task_count(self) -> int: """Count application tasks (excluding Celery's built-ins).""" return sum(1 for name in self._app.tasks if not name.startswith("celery.")) diff --git a/workers/queue_backend/pg_queue/result_backend.py b/workers/queue_backend/pg_queue/result_backend.py new file mode 100644 index 0000000000..208d184913 --- /dev/null +++ b/workers/queue_backend/pg_queue/result_backend.py @@ -0,0 +1,195 @@ +"""Postgres request-reply result store for the executor RPC (Phase 9). + +Replaces Celery's ``AsyncResult`` / result backend for the *blocking* executor +dispatch when it rides the PG transport. The PG executor consumer +(``worker-pg-executor``) writes a finished task's outcome here, keyed by the +caller-chosen reply key; the blocking caller polls :meth:`wait_for_result` +until the row appears or its timeout elapses. + +A row appears ONLY when the task finishes — ``status="completed"`` carrying the +``ExecutionResult.to_dict()`` payload, or ``status="failed"`` carrying the error +text if the task raised. Absence of a row means "not done yet"; there is +deliberately no ``pending`` state to maintain. + +Two deliberate properties: + +- **Idempotent writes** (``INSERT … ON CONFLICT DO NOTHING``): the PG queue is + at-least-once, so a redelivered executor message must not clobber an already + recorded result. First write wins. +- **Poll-based waiting, NOT ``LISTEN``/``NOTIFY``**: ``NOTIFY`` does not survive + transaction-pooled PgBouncer (the same constraint that makes the orchestrator + lock a TTL lease rather than ``pg_advisory_lock``). Polling with backoff is + pooling-safe and needs no persistent listener connection. + +Connection discipline mirrors :class:`~queue_backend.pg_queue.client.PgQueueClient`: +an injected connection is the caller's (tests); otherwise one is created lazily +from the backend ``DB_*`` env and owned here (rolled back on error, discarded + +reconnected when it goes bad). +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import time +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any, Self + +import psycopg2 + +from unstract.core.data_models import PgTaskStatus + +from .connection import create_pg_connection + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + +logger = logging.getLogger(__name__) + +# How long a stored result lives before the reaper's retention sweep may delete +# it. Defaults to the executor caller-timeout default so a result always +# outlives any caller still waiting on it. +DEFAULT_RETENTION_SECONDS = 3600 + +# First write wins — an at-least-once redelivery of the executor message must +# not overwrite a recorded result. +_STORE_SQL = ( + "INSERT INTO pg_task_result " + "(task_id, status, result, error, created_at, expires_at) " + "VALUES (%s, %s, %s::jsonb, %s, now(), now() + make_interval(secs => %s)) " + "ON CONFLICT (task_id) DO NOTHING" +) +_GET_SQL = "SELECT status, result, error FROM pg_task_result WHERE task_id = %s" + +# Poll cadence for wait_for_result: start tight (low latency for fast tasks), +# back off to a ceiling so a long-running task doesn't hammer the DB. +_POLL_INITIAL_SECONDS = 0.2 +_POLL_MAX_SECONDS = 2.0 + +# Status vocabulary — sourced from the shared enum in unstract.core so the writer +# (here) and the backend reader agree across the process boundary. Re-exported as +# plain strings for the SQL/tests. +STATUS_COMPLETED = PgTaskStatus.COMPLETED.value +STATUS_FAILED = PgTaskStatus.FAILED.value + + +class PgResultBackend: + """``store_result`` / ``get_result`` / ``wait_for_result`` over ``pg_task_result``.""" + + def __init__(self, conn: PgConnection | None = None) -> None: + self._conn = conn + # Injected connections belong to the caller — never close/recycle them. + self._owns_conn = conn is None + + @property + def conn(self) -> PgConnection: + if self._conn is None: + self._conn = create_pg_connection() + return self._conn + + @contextlib.contextmanager + def _cursor(self) -> Iterator[Any]: + """Yield a cursor; commit on success, roll back + recover on error.""" + conn = self.conn + try: + with conn.cursor() as cur: + yield cur + conn.commit() + except Exception as exc: + conn_dead = isinstance( + exc, (psycopg2.OperationalError, psycopg2.InterfaceError) + ) + try: + conn.rollback() + except Exception: + # A failed rollback proves the connection is unusable regardless + # of the original error subclass — recycle it (and surface why, + # rather than swallowing it silently). + logger.warning( + "PgResultBackend: rollback failed; treating connection as dead", + exc_info=True, + ) + conn_dead = True + if self._owns_conn and (conn_dead or conn.closed): + with contextlib.suppress(Exception): + conn.close() + self._conn = None + raise + + def store_result( + self, + task_id: str, + *, + result: dict[str, Any] | None = None, + error: str | None = None, + retention_seconds: int = DEFAULT_RETENTION_SECONDS, + ) -> None: + """Record a finished task's outcome under *task_id* (the reply key). + + ``result`` (a dict, e.g. ``ExecutionResult.to_dict()``) → ``completed``. + Otherwise → ``failed`` with ``error`` text. Idempotent: a second write + for the same key (at-least-once redelivery) is a no-op. + """ + if result is not None: + status, result_json, error_text = STATUS_COMPLETED, json.dumps(result), "" + else: + status, result_json, error_text = STATUS_FAILED, None, error or "" + with self._cursor() as cur: + cur.execute( + _STORE_SQL, + (str(task_id), status, result_json, error_text, retention_seconds), + ) + + def get_result(self, task_id: str) -> dict[str, Any] | None: + """Return ``{status, result, error}`` if the row exists, else ``None``. + + ``result`` is the decoded JSONB dict (psycopg2 parses ``jsonb`` to a + Python ``dict``); ``None`` means the task has not finished yet. + """ + with self._cursor() as cur: + cur.execute(_GET_SQL, (str(task_id),)) + row = cur.fetchone() + if row is None: + return None + status, result, error = row + return {"status": status, "result": result, "error": error} + + def wait_for_result( + self, + task_id: str, + timeout: float, + *, + poll_interval: float = _POLL_INITIAL_SECONDS, + ) -> dict[str, Any] | None: + """Block until the result row appears or *timeout* seconds elapse. + + Returns the ``{status, result, error}`` dict, or ``None`` on timeout. + Poll-based with exponential backoff (capped) — PgBouncer-safe, no + persistent listener. The final sleep is clamped so we never overshoot + the deadline. + """ + deadline = time.monotonic() + timeout + delay = poll_interval + while True: + row = self.get_result(task_id) + if row is not None: + return row + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + time.sleep(min(delay, remaining)) + delay = min(delay * 2, _POLL_MAX_SECONDS) + + def close(self) -> None: + """Close an owned connection (injected connections are the caller's).""" + if self._owns_conn and self._conn is not None and not self._conn.closed: + with contextlib.suppress(Exception): + self._conn.close() + self._conn = None + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> None: + self.close() diff --git a/workers/run-worker.sh b/workers/run-worker.sh index 91b18c2305..0ec399deec 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -56,6 +56,7 @@ readonly PG_ROLE_ORCH_GENERAL="pg-orchestrator-general" readonly PG_ROLE_FILEPROC="pg-fileproc" readonly PG_ROLE_CALLBACK="pg-callback" readonly PG_ROLE_SCHEDULER="pg-scheduler" +readonly PG_ROLE_EXECUTOR="pg-executor" declare -rA PG_CONSUMER_ROLES=( ["$PG_ROLE_ORCH_API"]="api_deployment;celery_api_deployments" ["$PG_ROLE_ORCH_GENERAL"]="general;celery" @@ -65,6 +66,10 @@ declare -rA PG_CONSUMER_ROLES=( # scheduler tick (Beat replacement). Distinct from the Celery 'scheduler' # worker (which Beat fires onto RabbitMQ). ["$PG_ROLE_SCHEDULER"]="scheduler;scheduler" + # Runs execute_extraction (the executor RPC) over PG — request-reply: writes + # the result to pg_task_result for the blocking caller. Queues mirror the + # Celery executor's CELERY_QUEUES_EXECUTOR. + ["$PG_ROLE_EXECUTOR"]="executor;celery_executor_legacy,celery_executor_agentic,celery_executor_agentic_table" ) declare -rA PG_QUEUE_MEMBERS=( ["$PG_QUEUE_CONSUMER_TYPE"]=1 @@ -74,6 +79,7 @@ declare -rA PG_QUEUE_MEMBERS=( ["$PG_ROLE_FILEPROC"]=1 ["$PG_ROLE_CALLBACK"]=1 ["$PG_ROLE_SCHEDULER"]=1 + ["$PG_ROLE_EXECUTOR"]=1 ) # The Celery transport set: every worker EXCEPT the PG-queue members — the # *complement* of the 'pg-queue' set, so the two transports' logs can be tailed @@ -112,6 +118,7 @@ declare -A WORKERS=( ["$PG_ROLE_FILEPROC"]="$PG_ROLE_FILEPROC" ["$PG_ROLE_CALLBACK"]="$PG_ROLE_CALLBACK" ["$PG_ROLE_SCHEDULER"]="$PG_ROLE_SCHEDULER" + ["$PG_ROLE_EXECUTOR"]="$PG_ROLE_EXECUTOR" # PG Queue reaper — leader-elected recovery loop (barrier-orphan sweep) ["reaper"]="$PG_QUEUE_REAPER_TYPE" ["pg-queue-reaper"]="$PG_QUEUE_REAPER_TYPE" diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index c1c50a66e8..05ed6e9869 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -10,7 +10,7 @@ import logging import os -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from celery import shared_task @@ -464,5 +464,62 @@ def test_parse_queue_list(): assert _parse_queue_list("a,,b") == ["a", "b"] # empties dropped +class TestRequestReply: + """Executor-RPC reply_key behaviour: store outcome + ack after one attempt; + drop branches store a definitive failure; a store failure still acks (no + expensive re-run). PgResultBackend is mocked so no DB is needed. + """ + + _RB = "queue_backend.pg_queue.consumer.PgResultBackend" + + def test_success_stores_result_and_acks(self): + client = MagicMock() + client.read.return_value = [_msg(1, {**_ok_payload(3, 4), "reply_key": "rk1"})] + with patch(self._RB) as rb_cls: + PgQueueConsumer(["q"], client=client).poll_once() + rb = rb_cls.return_value + rb.store_result.assert_called_once() + assert rb.store_result.call_args.args[0] == "rk1" + assert rb.store_result.call_args.kwargs["result"] == 7 # x + y + assert rb.store_result.call_args.kwargs["error"] is None + client.delete.assert_called_once_with(1) # acked + + def test_task_raise_stores_error_and_acks(self): + client = MagicMock() + client.read.return_value = [ + _msg(2, {"task_name": "test_pg_consumer.boom", "reply_key": "rk2"}) + ] + with patch(self._RB) as rb_cls: + PgQueueConsumer(["q"], client=client).poll_once() + rb = rb_cls.return_value + assert rb.store_result.call_args.kwargs["error"] is not None + assert rb.store_result.call_args.kwargs["result"] is None + client.delete.assert_called_once_with(2) # acked, NOT left for redelivery + + def test_unknown_task_stores_error_and_acks(self): + client = MagicMock() + client.read.return_value = [ + _msg(3, {"task_name": "nope.nope", "reply_key": "rk3"}) + ] + with patch(self._RB) as rb_cls: + PgQueueConsumer(["q"], client=client).poll_once() + rb = rb_cls.return_value + assert rb.store_result.call_args.kwargs["error"] is not None + client.delete.assert_called_once_with(3) + + def test_store_failure_on_success_path_still_acks(self, caplog): + client = MagicMock() + client.read.return_value = [_msg(4, {**_ok_payload(1, 1), "reply_key": "rk4"})] + with patch(self._RB) as rb_cls: + rb_cls.return_value.store_result.side_effect = RuntimeError("db down") + with caplog.at_level( + logging.ERROR, logger="queue_backend.pg_queue.consumer" + ): + PgQueueConsumer(["q"], client=client).poll_once() + # A store failure must NOT block the ack (avoids an expensive re-run). + client.delete.assert_called_once_with(4) + assert "FAILED to store request-reply result" in caplog.text + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/workers/tests/test_pg_result_backend.py b/workers/tests/test_pg_result_backend.py new file mode 100644 index 0000000000..a18ca012d0 --- /dev/null +++ b/workers/tests/test_pg_result_backend.py @@ -0,0 +1,103 @@ +"""Real-PG tests for PgResultBackend — the executor RPC result store. + +DB-gated via the ``pg_conn`` fixture (skips when Postgres is unreachable or the +``pg_queue`` migration isn't applied). Pins the request-reply contract: store/get +round-trip (completed + failed), idempotent first-write-wins, absent -> None, +wait returns when present, wait times out, and wait picks up a late write from +*another* connection (the cross-process request-reply path). +""" + +import os +import threading +import time +import uuid + +import pytest + +from queue_backend.pg_queue.connection import create_pg_connection +from queue_backend.pg_queue.result_backend import ( + STATUS_COMPLETED, + STATUS_FAILED, + PgResultBackend, +) + +_MARK = "pgtaskresult-test" + + +def _key() -> str: + return f"{_MARK}-{uuid.uuid4()}" + + +@pytest.fixture +def result_backend(pg_conn): + rb = PgResultBackend(conn=pg_conn) + yield rb + with pg_conn.cursor() as cur: + cur.execute("DELETE FROM pg_task_result WHERE task_id LIKE %s", (f"{_MARK}-%",)) + pg_conn.commit() + + +class TestStoreGet: + def test_store_completed_round_trips(self, result_backend): + k = _key() + result_backend.store_result(k, result={"success": True, "data": {"x": 1}}) + row = result_backend.get_result(k) + assert row["status"] == STATUS_COMPLETED + assert row["result"] == {"success": True, "data": {"x": 1}} + assert row["error"] == "" # no-NULL text convention + + def test_store_failed_round_trips(self, result_backend): + k = _key() + result_backend.store_result(k, error="boom") + row = result_backend.get_result(k) + assert row["status"] == STATUS_FAILED + assert row["error"] == "boom" + assert row["result"] is None + + def test_absent_returns_none(self, result_backend): + assert result_backend.get_result(_key()) is None + + def test_store_idempotent_first_write_wins(self, result_backend): + """At-least-once redelivery must not clobber a recorded result.""" + k = _key() + result_backend.store_result(k, result={"v": "first"}) + result_backend.store_result(k, error="second") # ON CONFLICT DO NOTHING + row = result_backend.get_result(k) + assert row["status"] == STATUS_COMPLETED + assert row["result"] == {"v": "first"} + + +class TestWait: + def test_wait_returns_immediately_when_present(self, result_backend): + k = _key() + result_backend.store_result(k, result={"ok": True}) + row = result_backend.wait_for_result(k, timeout=5) + assert row is not None + assert row["result"] == {"ok": True} + + def test_wait_times_out_returns_none(self, result_backend): + start = time.monotonic() + row = result_backend.wait_for_result(_key(), timeout=1, poll_interval=0.2) + assert row is None + assert time.monotonic() - start >= 0.9 # waited ~the full timeout + + def test_wait_picks_up_late_write_from_other_conn(self, result_backend): + """The real request-reply path: the waiter polls on its connection while + the result is committed from a separate connection mid-wait.""" + k = _key() + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + writer = PgResultBackend(conn=create_pg_connection(env_prefix="TEST_DB_")) + + def write_after_delay() -> None: + time.sleep(0.6) + writer.store_result(k, result={"late": True}) + + t = threading.Thread(target=write_after_delay) + t.start() + try: + row = result_backend.wait_for_result(k, timeout=10, poll_interval=0.2) + finally: + t.join() + writer.close() + assert row is not None + assert row["result"] == {"late": True} From 5a7d3d1dbd7f1835ca1888e507d4d7eb3e3cda0f Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:05:20 +0530 Subject: [PATCH 29/65] =?UTF-8?q?UN-3605=20[GATED-FEAT]=20PG=20Queue=20?= =?UTF-8?q?=E2=80=94=20structure=5Ftool=20executor=20RPC=20over=20Postgres?= =?UTF-8?q?=20(+=20register=20executors=20in=20PG=20consumer)=20(#2095)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3605 [GATED-FEAT] PG Queue — structure_tool executor RPC over Postgres (+ register executors in PG consumer) Routes the in-workflow structure_tool executor dispatch onto the Postgres executor RPC when the single pg_queue_enabled flag is on; Celery otherwise (zero-regression by construction). Next slice after UN-3603 (prompt-studio blocking path). Gated off by default. - workers queue_backend/pg_queue/executor_rpc.py (new): the workers twin of the backend module on worker primitives — resolve_executor_transport (master PG_QUEUE_TRANSPORT_ENABLED env, then the single pg_queue_enabled Flipt flag, fail-closed to Celery); PgExecutionDispatcher (enqueue execute_extraction with a unique reply_key via PgQueueClient, poll PgResultBackend; never raises — timeout/failure -> ExecutionResult.failure); RoutingExecutionDispatcher (per-call gate routing; async/callback stay on Celery); get_executor_dispatcher factory. - to_payload gains an optional reply_key (request-reply marker; fire-and-forget rows stay byte-identical). - structure_tool_task swaps its dispatcher factory to get_executor_dispatcher; the 3 blocking dispatch call sites are unchanged. - Fix (latent gap exposed by this slice): the PG executor consumer registered no executors ("No executor registered with name 'legacy'"). The @ExecutorRegistry.register side-effect import lived only in the Celery executor/worker.py, but the PG consumer bootstraps via executor/tasks.py. Import executor.executors from executor/tasks.py so any worker registering execute_extraction (Celery or PG) has the executors — also fixes the prompt-studio PG path from UN-3603. - sample.env: document the worker-side PG_QUEUE_TRANSPORT_ENABLED gate; fix a stale pg_queue_execution_enabled -> pg_queue_enabled doc reference. - Tests: workers executor_rpc unit suite (gate fail-closed matrix, zero-regression routing, never-raises dispatch); subprocess regression that importing executor.tasks alone registers the executors. Co-Authored-By: Claude Opus 4.8 * UN-3605 [GATED-FEAT] Address PR review (toolkit + greptile) Hardening + test-coverage from the review: - dispatch: row.get("status") (never-raises must not depend on producer keys); accept-and-ignore headers param for substitutability with the SDK/Routing dispatch shapes; log the EXECUTOR_RESULT_TIMEOUT parse fallback instead of swallowing it; surface the parse cause in the malformed-result error. - timeout branch: document the orphaned-task / retry-double-run risk (greptile); de-dup belongs at the file-execution layer (at-least-once + caller-timeout). - _wait_for_result: note the connection pin is bounded by file_processing prefork concurrency (vs the backend twin's close_old_connections per poll). - executor/tasks.py: reword the registration-import comment (it's executor/ worker.py, the Celery entrypoint, that the PG consumer bootstrap skips). - tests: org-less resolve bucketing (entity_id=run_id, no org in context); real _enqueue wiring (queue/org_id/payload via PgQueueClient.send); routing arg passthrough (gate-off forwards timeout+headers; gate-on drops headers); to_payload reply_key set/omitted; structure_tool factory-swap call site. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/sample.env | 2 +- workers/executor/tasks.py | 10 + .../file_processing/structure_tool_task.py | 11 +- .../queue_backend/pg_queue/executor_rpc.py | 316 ++++++++++++++++++ .../queue_backend/pg_queue/task_payload.py | 15 +- workers/sample.env | 8 + workers/tests/test_dispatch_pg.py | 9 + workers/tests/test_executor_registration.py | 46 +++ workers/tests/test_executor_rpc.py | 308 +++++++++++++++++ workers/tests/test_structure_tool_task.py | 38 +++ 10 files changed, 757 insertions(+), 6 deletions(-) create mode 100644 workers/queue_backend/pg_queue/executor_rpc.py create mode 100644 workers/tests/test_executor_registration.py create mode 100644 workers/tests/test_executor_rpc.py diff --git a/backend/sample.env b/backend/sample.env index 80406dfcd4..30450faff3 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -187,7 +187,7 @@ FLIPT_SERVICE_AVAILABLE=False # 9e PG-queue transport master-gate (kill-switch). Routing an execution onto the # PG queue requires ALL THREE: this gate True, FLIPT_SERVICE_AVAILABLE=True (else # the Flipt client returns False for every flag), and the Flipt flag -# `pg_queue_execution_enabled` on. Keep False until PG queue consumers are running +# `pg_queue_enabled` on. Keep False until PG queue consumers are running # in the fleet; set False for instant rollback. PG_QUEUE_TRANSPORT_ENABLED=False diff --git a/workers/executor/tasks.py b/workers/executor/tasks.py index 97faf2bf7f..1244e60b9c 100644 --- a/workers/executor/tasks.py +++ b/workers/executor/tasks.py @@ -5,6 +5,16 @@ ExecutionOrchestrator, and returns an ExecutionResult dict. """ +# Import the executor implementations so their ``@ExecutorRegistry.register`` +# decorators run before ``execute_extraction`` can be invoked. Coupling this to +# the task module (not only the Celery ``executor/worker.py`` entrypoint) ensures +# the registry is populated wherever the task is registered — in particular the PG +# executor consumer, which bootstraps via the root-worker import that loads +# ``executor/tasks.py`` (this module) but NOT ``executor/worker.py`` where this +# import historically lived; without it the consumer hits "No executor +# registered". Import is idempotent (module cached), so the Celery entrypoint +# importing it again is harmless. +import executor.executors # noqa: E402, F401 from queue_backend import worker_task from shared.clients import UsageAPIClient from shared.enums.task_enums import TaskName diff --git a/workers/file_processing/structure_tool_task.py b/workers/file_processing/structure_tool_task.py index cfa0e316a6..02c146b061 100644 --- a/workers/file_processing/structure_tool_task.py +++ b/workers/file_processing/structure_tool_task.py @@ -25,12 +25,15 @@ from file_processing.worker import app from queue_backend import FairnessKey, worker_task from queue_backend.fairness import WorkloadType +from queue_backend.pg_queue.executor_rpc import ( + RoutingExecutionDispatcher, + get_executor_dispatcher, +) from shared.enums.task_enums import TaskName from shared.infrastructure.context import StateStore from unstract.sdk1.constants import ToolEnv, UsageKwargs from unstract.sdk1.execution.context import ExecutionContext -from unstract.sdk1.execution.dispatcher import ExecutionDispatcher from unstract.sdk1.execution.result import ExecutionResult logger = logging.getLogger(__name__) @@ -276,7 +279,9 @@ def _execute_structure_tool_impl(params: dict) -> dict: ) platform_helper = _create_platform_helper(shim, file_execution_id) - dispatcher = ExecutionDispatcher(celery_app=app) + # Gate-routed: PG executor RPC when pg_queue_enabled is on, else the unchanged + # Celery ExecutionDispatcher (zero-regression by construction — see executor_rpc). + dispatcher = get_executor_dispatcher(celery_app=app) fs = _get_file_storage() # ---- Step 2: Fetch tool metadata ---- @@ -675,7 +680,7 @@ def _run_agentic_extraction( input_file_path: str, output_dir_path: str, tool_instance_metadata: dict, - dispatcher: ExecutionDispatcher, + dispatcher: RoutingExecutionDispatcher, shim: Any, file_execution_id: str, execution_id: str, diff --git a/workers/queue_backend/pg_queue/executor_rpc.py b/workers/queue_backend/pg_queue/executor_rpc.py new file mode 100644 index 0000000000..a68100c8a1 --- /dev/null +++ b/workers/queue_backend/pg_queue/executor_rpc.py @@ -0,0 +1,316 @@ +"""Executor-RPC transport routing for the PG path — workers side (Phase 9, ③b-2). + +The workers-side twin of ``backend/pg_queue/executor_rpc.py``. The executor "RPC" +is a synchronous request-reply: a caller (here, the ``structure_tool`` task in the +file_processing worker) sends an ``ExecutionContext`` to the executor worker and +blocks for the ``ExecutionResult``. The legacy transport is Celery — the SDK +``ExecutionDispatcher`` (``send_task`` + ``AsyncResult.get``). This module adds a +**parallel** Postgres transport that leaves Celery and the SDK completely +untouched (no SDK edit, no change to the ``execute_extraction`` task or the Celery +executor worker): + +- :class:`PgExecutionDispatcher` enqueues ``execute_extraction`` onto the PG queue + (via :class:`~queue_backend.pg_queue.client.PgQueueClient`) with a unique + ``reply_key`` and polls ``pg_task_result`` (via + :class:`~queue_backend.pg_queue.result_backend.PgResultBackend`) for the reply — + same ``.dispatch()`` contract as the SDK dispatcher (never raises; + failure/timeout → ``ExecutionResult.failure``). The already-running + ``worker-pg-executor`` consumer runs the task and writes the reply, so this side + only adds the enqueue + poll halves. +- :func:`resolve_executor_transport` is the gate: master + ``PG_QUEUE_TRANSPORT_ENABLED`` (env, the workers analogue of the backend's + Django setting) then the **single** Flipt flag ``pg_queue_enabled`` — the same + flag the execution path uses, so one flip turns the whole PG-queue feature + on/off. Fails closed to Celery. +- :class:`RoutingExecutionDispatcher` is what ``structure_tool`` gets from + :func:`get_executor_dispatcher`: ``dispatch()`` picks PG-vs-Celery **per call** + (read at dispatch time → flipping the flag is an instant, no-redeploy + rollout/rollback); ``dispatch_async`` / ``dispatch_with_callback`` always + delegate to Celery (the callback path is a later slice). + +Zero-regression: gate off ⇒ every method delegates to the unchanged Celery +``ExecutionDispatcher`` and no ``pg_task_result`` row is created. + +TODO(shared): ``resolve_executor_transport`` and the reply_key/poll orchestration +mirror the backend module almost verbatim — only the transport primitives differ +(psycopg2 here vs Django ORM there). A later slice can lift the shared logic into +``unstract.core`` so the gate/contract lives in one place. +""" + +from __future__ import annotations + +import logging +import os +import uuid +from typing import TYPE_CHECKING, Any + +from unstract.core.data_models import PgTaskStatus +from unstract.flags.feature_flag import check_feature_flag_status +from unstract.sdk1.execution.dispatcher import ExecutionDispatcher +from unstract.sdk1.execution.result import ExecutionResult + +from .client import PgQueueClient +from .result_backend import PgResultBackend +from .task_payload import to_payload + +if TYPE_CHECKING: + from unstract.sdk1.execution.context import ExecutionContext + +logger = logging.getLogger(__name__) + +# The single PG-queue rollout flag — same key the execution path and scheduler +# read. Defined in backend/pg_queue/flags.py too; kept as a literal here because +# workers can't import backend code (see TODO(shared) in the module docstring). +PG_QUEUE_FLAG_KEY = "pg_queue_enabled" +# Master kill-switch + deploy-ordering gate. The workers analogue of the backend's +# ``settings.PG_QUEUE_TRANSPORT_ENABLED`` — read straight from the env here. +_MASTER_GATE_ENV = "PG_QUEUE_TRANSPORT_ENABLED" + +_EXECUTE_TASK = "execute_extraction" +# Mirror the SDK's queue-per-executor convention so the PG executor queue name +# matches the Celery one (the worker-pg-executor consumer subscribes to these). +_QUEUE_PREFIX = "celery_executor_" +# Caller-side wait default — mirrors the SDK dispatcher (EXECUTOR_RESULT_TIMEOUT +# env, else 3600s) so a PG-routed caller waits exactly as long as a Celery one. +_DEFAULT_TIMEOUT_ENV = "EXECUTOR_RESULT_TIMEOUT" +_DEFAULT_TIMEOUT = 3600 + + +def resolve_executor_transport(context: ExecutionContext) -> bool: + """True → route this executor dispatch over PG; False → Celery (default). + + Mirrors the backend ``resolve_executor_transport``: master-gated by the + ``PG_QUEUE_TRANSPORT_ENABLED`` env, then the **single** ``pg_queue_enabled`` + Flipt flag (shared across the whole PG-queue feature), bucketed per org. + **Fails closed to Celery** on a closed gate, a blind Flipt, or any error — so + the executor never silently loses its transport. + """ + if os.environ.get(_MASTER_GATE_ENV, "false").lower() != "true": + return False + if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": + logger.warning( + "resolve_executor_transport: gate ON but FLIPT_SERVICE_AVAILABLE != " + "true (Flipt blind); using Celery" + ) + return False + org = getattr(context, "organization_id", None) + # %-bucket keyed on org; fall back to run_id so a context without an org still + # resolves deterministically (mirrors the backend resolver). + entity_id = str(org or getattr(context, "run_id", "") or "default") + flag_context = {"executor_name": str(context.executor_name)} + if org: + flag_context["organization_id"] = str(org) + try: + enabled = check_feature_flag_status( + flag_key=PG_QUEUE_FLAG_KEY, entity_id=entity_id, context=flag_context + ) + except Exception: + logger.warning( + "resolve_executor_transport: Flipt check failed; using Celery", + exc_info=True, + ) + return False + return bool(enabled) + + +class PgExecutionDispatcher: + """PG request-reply executor dispatch — drop-in for ``ExecutionDispatcher.dispatch``. + + Enqueues ``execute_extraction`` with a unique ``reply_key`` and blocks on + ``pg_task_result`` until the executor consumer records the result or the + timeout elapses. Honours the same contract as the SDK dispatcher: it never + raises and converts a timeout/failure into ``ExecutionResult.failure`` so + callers can branch on ``result.success`` identically on either transport. + """ + + def dispatch( + self, + context: ExecutionContext, + timeout: int | None = None, + headers: dict[str, Any] | None = None, + ) -> ExecutionResult: + # ``headers`` is accepted (and ignored) for substitutability with the SDK + # ``ExecutionDispatcher.dispatch`` / ``RoutingExecutionDispatcher.dispatch`` + # shapes — the PG path carries org/routing via the enqueue payload, not + # Celery headers, so fairness headers are intentionally not forwarded. + if timeout is None: + # Guard the env parse so a misconfigured EXECUTOR_RESULT_TIMEOUT can't + # raise out of dispatch() (the never-raises contract). + try: + timeout = int(os.environ.get(_DEFAULT_TIMEOUT_ENV, _DEFAULT_TIMEOUT)) + except (TypeError, ValueError): + # Don't swallow silently — an operator who fat-fingers the value + # would otherwise wait the 3600s default with no signal. + logger.warning( + "PG executor dispatch: invalid %s=%r; falling back to %ss", + _DEFAULT_TIMEOUT_ENV, + os.environ.get(_DEFAULT_TIMEOUT_ENV), + _DEFAULT_TIMEOUT, + ) + timeout = _DEFAULT_TIMEOUT + reply_key = str(uuid.uuid4()) + queue = f"{_QUEUE_PREFIX}{context.executor_name}" + org = str(getattr(context, "organization_id", "") or "") + try: + self._enqueue(queue, context, reply_key, org) + except Exception as exc: + logger.exception( + "PG executor dispatch: enqueue failed (executor=%s run_id=%s)", + context.executor_name, + context.run_id, + ) + return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}") + logger.info( + "PG executor dispatch: enqueued reply_key=%s queue=%s run_id=%s " + "timeout=%ss; waiting for result...", + reply_key, + queue, + context.run_id, + timeout, + ) + try: + row = self._wait_for_result(reply_key, timeout) + except Exception as exc: + # Honour the never-raises contract even if the poll connection dies. + logger.exception( + "PG executor dispatch: wait failed (reply_key=%s run_id=%s)", + reply_key, + context.run_id, + ) + return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}") + if row is None: + # On timeout the executor task may still be running on the consumer; + # it will write its outcome under this reply_key, but we've already + # given up reading it (the reaper retention-sweeps the orphan row). If + # the workflow engine retries the file execution, it re-dispatches with + # a FRESH reply_key — so two executor tasks for the same file can + # overlap (double LLM spend / duplicate writes). De-duping that belongs + # at the file-execution layer, not here; this transport stays at-least- + # once + caller-timeout by design. + logger.warning( + "PG executor dispatch: TIMEOUT after %ss (reply_key=%s run_id=%s) — " + "the executor task may still be running", + timeout, + reply_key, + context.run_id, + ) + return ExecutionResult.failure( + error=f"TimeoutError: executor reply not received within {timeout}s" + ) + # ``.get`` (not ``[...]``) so a result row missing ``status`` can't raise + # out of dispatch() — the never-raises contract must not depend on the + # producer always writing every key. + if ( + row.get("status") == PgTaskStatus.COMPLETED.value + and row.get("result") is not None + ): + try: + return ExecutionResult.from_dict(row["result"]) + except Exception as exc: + # A malformed completed row becomes a failure result, not a raise. + # Surface the parse cause (like the enqueue/wait paths) so a UI + # reading result.error isn't left with an opaque message. + logger.exception( + "PG executor dispatch: malformed completed result " + "(reply_key=%s run_id=%s)", + reply_key, + context.run_id, + ) + return ExecutionResult.failure( + error=( + f"Malformed executor result ({type(exc).__name__}) " + f"for reply_key {reply_key}" + ) + ) + logger.warning( + "PG executor dispatch: executor reported failure (reply_key=%s " + "run_id=%s): %s", + reply_key, + context.run_id, + row.get("error") or "(no error)", + ) + return ExecutionResult.failure(error=row.get("error") or "executor task failed") + + @staticmethod + def _enqueue( + queue: str, context: ExecutionContext, reply_key: str, org_id: str + ) -> None: + """Enqueue the ``execute_extraction`` request-reply message. + + A short-lived client owns its connection for just the insert (which + commits internally) so the message is durably visible to the + ``worker-pg-executor`` consumer before we begin polling — and no + connection is pinned for the whole (possibly long) RPC. + """ + payload = to_payload( + _EXECUTE_TASK, + args=[context.to_dict()], + queue=queue, + reply_key=reply_key, + ) + with PgQueueClient() as client: + client.send(queue, payload, org_id=org_id) + + @staticmethod + def _wait_for_result(reply_key: str, timeout: float) -> dict[str, Any] | None: + """Poll ``pg_task_result`` until the row appears or *timeout* elapses. + + Poll-based with capped backoff (PgBouncer-safe; no LISTEN/NOTIFY). The + backend owns one connection for the duration of the wait and closes it on + exit, so a long RPC never leaks a connection. The pin is bounded: a + file_processing worker runs ``--pool=prefork`` with + ``WORKER_FILE_PROCESSING_CONCURRENCY`` (default 4) processes, and each + dispatches sequentially, so at most ~concurrency connections are held for + up to ``EXECUTOR_RESULT_TIMEOUT``. (The backend twin instead releases via + ``close_old_connections`` between polls; if file_processing concurrency is + raised materially, do the same here.) + """ + with PgResultBackend() as rb: + return rb.wait_for_result(reply_key, timeout) + + +class RoutingExecutionDispatcher: + """Gate-routed executor dispatcher returned by :func:`get_executor_dispatcher`. + + ``dispatch()`` chooses PG vs Celery per call (instant rollout/rollback); + ``dispatch_async`` / ``dispatch_with_callback`` always delegate to Celery — + the async/callback path stays on Celery until a later continuation slice. + Duck-typed against the SDK ``ExecutionDispatcher`` so call sites are unchanged. + """ + + def __init__(self, celery_app: object | None = None) -> None: + self._celery = ExecutionDispatcher(celery_app=celery_app) + self._pg = PgExecutionDispatcher() + + def dispatch( + self, + context: ExecutionContext, + timeout: int | None = None, + headers: dict[str, Any] | None = None, + ) -> ExecutionResult: + if resolve_executor_transport(context): + logger.info( + "Executor RPC → PG transport (executor=%s run_id=%s)", + context.executor_name, + context.run_id, + ) + # PG carries org/routing via the enqueue payload, not Celery headers, + # so the fairness headers are intentionally not forwarded here (parity + # with the backend executor RPC). + return self._pg.dispatch(context, timeout=timeout) + return self._celery.dispatch(context, timeout=timeout, headers=headers) + + def dispatch_async( + self, context: ExecutionContext, headers: dict[str, Any] | None = None + ) -> str: + return self._celery.dispatch_async(context, headers=headers) + + def dispatch_with_callback(self, context: ExecutionContext, **kwargs: Any) -> Any: + return self._celery.dispatch_with_callback(context, **kwargs) + + +def get_executor_dispatcher( + celery_app: object | None = None, +) -> RoutingExecutionDispatcher: + """Factory: the gate-routed executor dispatcher (PG when enabled, else Celery).""" + return RoutingExecutionDispatcher(celery_app=celery_app) diff --git a/workers/queue_backend/pg_queue/task_payload.py b/workers/queue_backend/pg_queue/task_payload.py index 919914bfae..da80f6c155 100644 --- a/workers/queue_backend/pg_queue/task_payload.py +++ b/workers/queue_backend/pg_queue/task_payload.py @@ -32,12 +32,23 @@ def to_payload( kwargs: Mapping[str, Any] | None = None, queue: str | None = None, fairness: FairnessKey | None = None, + reply_key: str | None = None, ) -> TaskPayload: - """Build the JSON-serialisable task payload for the PG queue.""" - return TaskPayload( + """Build the JSON-serialisable task payload for the PG queue. + + ``reply_key`` marks a **request-reply** dispatch (the executor RPC on PG): + the executor consumer writes the task's result/error to ``pg_task_result`` + under it for the blocking caller to poll. Omitted = fire-and-forget. + """ + payload = TaskPayload( task_name=task_name, args=list(args) if args is not None else [], kwargs=dict(kwargs) if kwargs is not None else {}, queue=queue, fairness=fairness.to_dict() if fairness is not None else None, ) + # Only set for request-reply dispatches — keeps fire-and-forget rows + # byte-identical to before this field existed (mirrors the backend producer). + if reply_key is not None: + payload["reply_key"] = reply_key + return payload diff --git a/workers/sample.env b/workers/sample.env index 8ad7df3612..0cf9fb6b2a 100644 --- a/workers/sample.env +++ b/workers/sample.env @@ -473,3 +473,11 @@ FLIPT_SERVICE_AVAILABLE=False EVALUATION_SERVER_IP=unstract-flipt EVALUATION_SERVER_PORT=9005 PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python + +# PG-queue transport master-gate (kill-switch), worker side. Mirrors the backend's +# PG_QUEUE_TRANSPORT_ENABLED: the file_processing worker reads it to route the +# structure_tool executor RPC onto the PG queue. Routing onto PG requires ALL +# THREE: this gate True, FLIPT_SERVICE_AVAILABLE=True, and the Flipt flag +# `pg_queue_enabled` on. Keep False until the worker-pg-executor consumer is +# running in the fleet; set False for instant rollback. +PG_QUEUE_TRANSPORT_ENABLED=False diff --git a/workers/tests/test_dispatch_pg.py b/workers/tests/test_dispatch_pg.py index e9cde91824..c97a2664fa 100644 --- a/workers/tests/test_dispatch_pg.py +++ b/workers/tests/test_dispatch_pg.py @@ -66,6 +66,15 @@ def test_json_serialisable(self): # Must round-trip through JSON (it's stored in a JSONB column). json.dumps(to_payload("t", args=[1], kwargs={"a": "b"}, fairness=fairness)) + def test_reply_key_set_when_passed(self): + # The request-reply marker (executor RPC): the consumer keys the result + # row on it; a drop/mis-key would make the caller block until timeout. + assert to_payload("t", reply_key="rk")["reply_key"] == "rk" + + def test_reply_key_omitted_when_absent(self): + # Fire-and-forget rows stay byte-identical to before the field existed. + assert "reply_key" not in to_payload("t") + # --- Integration: dispatch() lands a decodable row in pg_queue_message --- # Uses the shared ``pg_client`` fixture from conftest.py. diff --git a/workers/tests/test_executor_registration.py b/workers/tests/test_executor_registration.py new file mode 100644 index 0000000000..aec766bac2 --- /dev/null +++ b/workers/tests/test_executor_registration.py @@ -0,0 +1,46 @@ +"""Regression: the executor registry must be populated by importing the task module. + +The PG executor consumer (``pg_queue_consumer``) bootstraps a worker by importing +the source worker's ``tasks.py`` — it does NOT import ``executor/worker.py`` (the +Celery entrypoint that historically held ``import executor.executors``). So if the +``@ExecutorRegistry.register`` side-effect import lives only in ``worker.py``, the +PG executor runs ``execute_extraction`` against an EMPTY registry and every +extraction fails with ``No executor registered with name 'legacy'. Available: +(none)``. + +This is pinned in a fresh interpreter that imports ONLY ``executor.tasks`` (exactly +what the PG consumer does), so import caching from the rest of the suite can't mask +a regression. +""" + +import os +import subprocess +import sys + + +def test_importing_executor_tasks_registers_executors(): + """Importing executor.tasks alone must register the bundled executors. + + Mirrors the PG-queue consumer's bootstrap (tasks.py, not worker.py). A fresh + subprocess avoids the suite's other imports pre-populating the registry. + """ + code = ( + "import executor.tasks\n" + "from unstract.sdk1.execution.registry import ExecutorRegistry\n" + "reg = getattr(ExecutorRegistry, '_registry', {})\n" + "assert 'legacy' in reg, f'registry empty/missing legacy: {sorted(reg)}'\n" + "print('OK', sorted(reg))\n" + ) + env = {**os.environ, "WORKER_TYPE": "executor"} + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + env=env, + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + ) + assert result.returncode == 0, ( + f"executor.tasks import did not register executors.\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "OK" in result.stdout diff --git a/workers/tests/test_executor_rpc.py b/workers/tests/test_executor_rpc.py new file mode 100644 index 0000000000..ebf93b5889 --- /dev/null +++ b/workers/tests/test_executor_rpc.py @@ -0,0 +1,308 @@ +"""Tests for the workers-side executor-RPC transport routing (Phase 9, ③b-2). + +DB-free: the env gate / Flipt / the enqueue + poll halves are mocked. Pins the +gate's fail-closed matrix and — the load-bearing zero-regression property — that +with the gate off ``RoutingExecutionDispatcher`` delegates EVERY mode to the +unchanged Celery ``ExecutionDispatcher`` and never touches the PG path. Mirrors +``backend/pg_queue/tests/test_executor_rpc.py`` (the backend twin) adapted to the +worker primitives: an env master-gate instead of a Django setting, and the result +row is a plain ``dict`` (``PgResultBackend``) instead of a Django model. +""" + +from unittest.mock import MagicMock, patch + +from queue_backend.pg_queue.executor_rpc import ( + PgExecutionDispatcher, + RoutingExecutionDispatcher, + resolve_executor_transport, +) + +_MOD = "queue_backend.pg_queue.executor_rpc" + + +def _completed(result: dict) -> dict: + return {"status": "completed", "result": result, "error": ""} + + +def _ok_result() -> dict: + return {"success": True, "data": {"x": 1}, "metadata": {}, "error": None} + + +class TestPgExecutionDispatcherDispatch: + """The load-bearing contract: never raises; timeout/failure → failure result. + + DB-free — the ``_enqueue`` and ``_wait_for_result`` halves are mocked. + """ + + @staticmethod + def _ctx() -> MagicMock: + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "r" + c.organization_id = "o" + c.to_dict.return_value = {"run_id": "r"} + return c + + def test_enqueue_failure_returns_failure_not_raise(self): + with patch.object( + PgExecutionDispatcher, "_enqueue", side_effect=RuntimeError("db down") + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + assert "RuntimeError" in res.error + + def test_wait_failure_returns_failure_not_raise(self): + with ( + patch.object(PgExecutionDispatcher, "_enqueue"), + patch.object( + PgExecutionDispatcher, + "_wait_for_result", + side_effect=RuntimeError("conn died"), + ), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + assert "RuntimeError" in res.error + + def test_timeout_returns_failure(self): + with ( + patch.object(PgExecutionDispatcher, "_enqueue"), + patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=None), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=3) + assert res.success is False + assert "within 3s" in res.error + + def test_completed_row_returns_result(self): + with ( + patch.object(PgExecutionDispatcher, "_enqueue"), + patch.object( + PgExecutionDispatcher, + "_wait_for_result", + return_value=_completed(_ok_result()), + ), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is True + + def test_failed_row_returns_error(self): + row = {"status": "failed", "result": None, "error": "boom"} + with ( + patch.object(PgExecutionDispatcher, "_enqueue"), + patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + assert res.error == "boom" + + def test_failed_row_empty_error_falls_back(self): + row = {"status": "failed", "result": None, "error": ""} + with ( + patch.object(PgExecutionDispatcher, "_enqueue"), + patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + assert "executor task failed" in res.error + + def test_completed_but_result_none_is_failure(self): + row = {"status": "completed", "result": None, "error": ""} + with ( + patch.object(PgExecutionDispatcher, "_enqueue"), + patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + + def test_malformed_completed_row_is_failure_not_raise(self): + with ( + patch.object(PgExecutionDispatcher, "_enqueue"), + patch.object( + PgExecutionDispatcher, + "_wait_for_result", + return_value=_completed({"bad": "shape"}), + ), + ): + res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + assert res.success is False + assert "Malformed" in res.error + + def test_timeout_none_reads_env_then_default(self, monkeypatch): + monkeypatch.setenv("EXECUTOR_RESULT_TIMEOUT", "42") + seen = {} + + def fake_wait(reply_key, timeout): + seen["timeout"] = timeout + return None + + with ( + patch.object(PgExecutionDispatcher, "_enqueue"), + patch.object( + PgExecutionDispatcher, "_wait_for_result", side_effect=fake_wait + ), + ): + # No explicit timeout arg → falls back to the env/default. + PgExecutionDispatcher().dispatch(self._ctx()) + assert seen["timeout"] == 42 + + def test_timeout_none_bad_env_falls_back_to_default(self, monkeypatch): + monkeypatch.setenv("EXECUTOR_RESULT_TIMEOUT", "not-an-int") + seen = {} + + def fake_wait(reply_key, timeout): + seen["timeout"] = timeout + return None + + with ( + patch.object(PgExecutionDispatcher, "_enqueue"), + patch.object( + PgExecutionDispatcher, "_wait_for_result", side_effect=fake_wait + ), + ): + PgExecutionDispatcher().dispatch(self._ctx()) # must not raise + assert seen["timeout"] == 3600 # _DEFAULT_TIMEOUT + + +def _ctx(org: str | None = "org1") -> MagicMock: + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "run-1" + c.organization_id = org + return c + + +class TestResolveExecutorTransport: + def test_master_gate_off_is_celery(self, monkeypatch): + monkeypatch.delenv("PG_QUEUE_TRANSPORT_ENABLED", raising=False) + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with patch(f"{_MOD}.check_feature_flag_status") as flag: + assert resolve_executor_transport(_ctx()) is False + flag.assert_not_called() # gate off → Flipt never consulted + + def test_flipt_unavailable_is_celery(self, monkeypatch): + monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") + with patch(f"{_MOD}.check_feature_flag_status") as flag: + assert resolve_executor_transport(_ctx()) is False + flag.assert_not_called() + + def test_flag_true_is_pg_keyed_on_org(self, monkeypatch): + monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with patch( + f"{_MOD}.check_feature_flag_status", return_value=True + ) as flag: + assert resolve_executor_transport(_ctx("orgX")) is True + assert flag.call_args.kwargs["entity_id"] == "orgX" + # The single shared PG-queue flag (not a per-subsystem flag). + assert flag.call_args.kwargs["flag_key"] == "pg_queue_enabled" + + def test_flag_false_is_celery(self, monkeypatch): + monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with patch(f"{_MOD}.check_feature_flag_status", return_value=False): + assert resolve_executor_transport(_ctx()) is False + + def test_flipt_error_fails_closed_to_celery(self, monkeypatch): + monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with patch( + f"{_MOD}.check_feature_flag_status", side_effect=RuntimeError("down") + ): + assert resolve_executor_transport(_ctx()) is False + + def test_org_less_context_buckets_on_run_id(self, monkeypatch): + """No org → entity_id falls back to run_id and org is absent from context. + + Guards the org-less bucketing so cross-org/run-only contexts resolve + deterministically instead of shipping a bogus "None" org. + """ + monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") + with patch( + f"{_MOD}.check_feature_flag_status", return_value=True + ) as flag: + assert resolve_executor_transport(_ctx(org=None)) is True + assert flag.call_args.kwargs["entity_id"] == "run-1" + assert "organization_id" not in flag.call_args.kwargs["context"] + + +class TestPgExecutionDispatcherEnqueueWiring: + """The actual PG-transport wiring: queue name, payload shape, org_id. + + These are the *only* routing/identity carried on the PG path (Celery headers + are dropped), so a bug here misroutes or breaks org-fairness. ``to_payload`` + runs for real; only ``PgQueueClient`` and the wait are mocked. + """ + + @staticmethod + def _ctx(): + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "r" + c.organization_id = "org9" + c.to_dict.return_value = {"run_id": "r"} + return c + + def test_enqueue_sends_queue_payload_and_org(self): + client = MagicMock() + client.__enter__.return_value = client # `with PgQueueClient() as c` → c is client + with ( + patch(f"{_MOD}.PgQueueClient", return_value=client), + patch.object( + PgExecutionDispatcher, + "_wait_for_result", + return_value=_completed(_ok_result()), + ), + ): + PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) + client.send.assert_called_once() + args, kwargs = client.send.call_args + queue_arg, payload_arg = args[0], args[1] + assert queue_arg == "celery_executor_legacy" + assert kwargs["org_id"] == "org9" + assert payload_arg["task_name"] == "execute_extraction" + assert payload_arg["args"] == [{"run_id": "r"}] + assert payload_arg["reply_key"] # request-reply marker present (a uuid) + + +class TestRoutingZeroRegression: + @staticmethod + def _build(): + # Patch both sub-dispatchers at construction; the instances are captured + # in __init__ so they remain mocked after the context exits. + with ( + patch(f"{_MOD}.ExecutionDispatcher") as celery_cls, + patch(f"{_MOD}.PgExecutionDispatcher") as pg_cls, + ): + dispatcher = RoutingExecutionDispatcher(celery_app="app") + return dispatcher, celery_cls.return_value, pg_cls.return_value + + def test_gate_off_forwards_timeout_and_headers_to_celery(self): + """Zero-regression: gate off → Celery gets timeout AND headers unchanged.""" + dispatcher, celery, pg = self._build() + ctx = _ctx() + hdrs = {"x-fairness-key": {"org_id": "o"}} + with patch(f"{_MOD}.resolve_executor_transport", return_value=False): + dispatcher.dispatch(ctx, timeout=9, headers=hdrs) + celery.dispatch.assert_called_once_with(ctx, timeout=9, headers=hdrs) + pg.dispatch.assert_not_called() # the zero-regression guarantee + + def test_gate_on_passes_timeout_to_pg_and_drops_headers(self): + """Gate on → PG gets the timeout but NOT the Celery headers (intentional).""" + dispatcher, celery, pg = self._build() + ctx = _ctx() + with patch(f"{_MOD}.resolve_executor_transport", return_value=True): + dispatcher.dispatch(ctx, timeout=7, headers={"x-fairness-key": {"o": 1}}) + pg.dispatch.assert_called_once_with(ctx, timeout=7) + assert "headers" not in pg.dispatch.call_args.kwargs + celery.dispatch.assert_not_called() + + def test_async_and_callback_always_celery(self): + """The callback/async path stays on Celery regardless of the gate (a later slice).""" + dispatcher, celery, pg = self._build() + dispatcher.dispatch_async(_ctx()) + dispatcher.dispatch_with_callback(_ctx(), on_success=None) + celery.dispatch_async.assert_called_once() + celery.dispatch_with_callback.assert_called_once() + pg.dispatch.assert_not_called() diff --git a/workers/tests/test_structure_tool_task.py b/workers/tests/test_structure_tool_task.py index 8bdb541eca..a8fb5eca65 100644 --- a/workers/tests/test_structure_tool_task.py +++ b/workers/tests/test_structure_tool_task.py @@ -17,8 +17,11 @@ from __future__ import annotations +from unittest.mock import patch + import pytest +from file_processing import structure_tool_task as st from file_processing.structure_tool_task import _fairness_headers from queue_backend.fairness import WorkloadType @@ -50,5 +53,40 @@ def test_workload_type_is_non_api_not_api(self): assert wire["x-fairness-key"]["workload_type"] != WorkloadType.API.value +class TestDispatcherFactory: + """Pin the call-site swap this PR makes: the impl builds its dispatcher via + ``get_executor_dispatcher(celery_app=app)`` (the gate-routed dispatcher), not + the raw SDK ``ExecutionDispatcher``. A mis-import or wrong arg would otherwise + silently bypass the PG routing with nothing failing. + """ + + @staticmethod + def _params() -> dict: + return { + "organization_id": "org1", + "file_execution_id": "fe1", + "tool_instance_metadata": {}, + "platform_service_api_key": "sk", + "input_file_path": "/in/f.pdf", + "output_dir_path": "/out", + "source_file_name": "f.pdf", + "execution_data_dir": "/data", + } + + def test_impl_builds_dispatcher_via_factory_with_app(self): + # Stub everything up to (and just past) the dispatcher construction, then + # raise to stop before the heavy tool-metadata work runs. + with ( + patch("executor.executor_tool_shim.ExecutorToolShim"), + patch.object(st, "_create_platform_helper"), + patch.object(st, "_get_file_storage"), + patch.object(st, "get_executor_dispatcher") as factory, + patch.object(st, "_fetch_tool_metadata", side_effect=RuntimeError("stop")), + ): + with pytest.raises(RuntimeError, match="stop"): + st._execute_structure_tool_impl(self._params()) + factory.assert_called_once_with(celery_app=st.app) + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From ab80a494bdf23c07c49db5a2614e039c234c1298 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:28:51 +0530 Subject: [PATCH 30/65] =?UTF-8?q?UN-3606=20[FIX]=20PG=20Queue=20=E2=80=94?= =?UTF-8?q?=20prefork=20the=20consumer=20so=20file=20batches=20+=20executo?= =?UTF-8?q?r=20RPCs=20run=20in=20parallel=20(#2096)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3606 [FIX] PG Queue — prefork the consumer so file batches + executor RPCs run in parallel The PG file-processing path ran files SERIALLY: worker-pg-fileproc was a single batch=1 consumer, so multi-file ETL batches drained one at a time (Celery runs them in parallel via prefork --concurrency). Same single-consumer bottleneck on worker-pg-executor serialized the executor RPCs (the LLM extraction), so even parallel file fan-out gained nothing end-to-end. Fix: a prefork supervisor for the PG-queue consumer launcher — the PG analogue of Celery --pool=prefork --concurrency=N. - supervisor.py (new): when WORKER_PG_QUEUE_CONSUMER_CONCURRENCY > 1, fork N isolated consumer children (each does its own worker bootstrap, so no connections are inherited across the fork), monitor + re-fork dead children, and own a single fleet-liveness endpoint (503 when the oldest child stalls past the threshold). SKIP LOCKED distributes batches across children AND replicas; a single execution is still capped by MAX_PARALLEL_FILE_BATCHES; total live parallelism = concurrency x replicas (k8s HPA scales replicas). - The consumer code is UNCHANGED — each child is the current single-threaded PgQueueConsumer.run(); concurrency is purely a launch concern. CONCURRENCY=1 (default) keeps the byte-identical single-process path, so every other PG consumer is non-regressive. - consumer.py: extract build_consumer_from_env()/consumer_env() so the children and main() build identical consumers. - docker-compose: CONCURRENCY=4 on worker-pg-fileproc AND worker-pg-executor (mirrors WORKER_FILE_PROCESSING_CONCURRENCY). Also raise their VT_SECONDS to 3660 — process_file_batch/execute_extraction block on the executor up to 3600s, so the prior vt=30 default would re-claim a long batch mid-run (latent double-run); health-stale sits just above vt. - Tests: env-knob parse/clamp/validation, fleet-staleness calc, reap/restart matrix (dead -> re-fork, live -> left, shutdown -> not resurrected). Process model matches Celery prefork (the cloud-trusted choice): full process isolation, a crash is contained + re-forked, its in-flight message redelivers via vt (at-least-once). Dev-tested end-to-end: 4 fileproc + 4 executor children, a 2-file ETL runs fully parallel (both files + both executor RPCs overlap on separate children; ~25s vs ~42s serial), child crash auto-restarts, health green. Co-Authored-By: Claude Opus 4.8 * UN-3606 [FIX] Address PR review (toolkit + greptile) — supervisor hardening Critical/High: - Crash-loop no longer hides a wedged fleet from the probe: re-fork does NOT reseed the heartbeat (a never-polling slot ages), and a slot that dies immediately N times in a row forces freshness=inf → 503 (k8s restarts the pod). - os.fork() wrapped: initial-fleet failure fails fast with an actionable message; reap-path failure logs + leaves the slot for the next tick (no uncaught crash / no SIGTERM storm against healthy children). - Heartbeat publish loop guarded (try/except + log) so a transient error can't silently kill the thread and false-stale a healthy child. - Children reset SIGTERM/SIGINT to SIG_DFL immediately after fork — a signal in the fork→run window no longer fires the parent's _on_term against stale pids. Medium: - _Fleet class owns pid/last_fork/heartbeat/crash-count/restart-schedule with a validated slot and consistent reap (replaces three loose slot-keyed dicts). - Re-fork backoff is a scheduled not-before (non-blocking), re-checking stopping before each fork — no in-loop sleep serialising recovery, no child spawned into shutdown. - Liveness bind: EADDRINUSE logged at error (vs transient), and liveness_probe_bound surfaced in the status payload. - _join_children: per-child grace budget (one slow child can't starve the rest); _wait_for_exit helper extracted. Low: - real Callable annotations (drop noqas); HEALTH_PORT typed int|None; comment fixes (heartbeat writer reasoning, "JSON body differs" for the fleet probe); WORKER_TYPE selection deduped into pg_queue_consumer/_bootstrap.py. Tests rewritten + expanded: _Fleet bookkeeping/crash-loop/freshness, reap scheduling, restart-due gating, fork OSError + child hard-exit guard, _wait_for_exit + _join_children SIGKILL escalation, health no-port/bind-error. 32 tests. Re-validated live: 4 children, fleet health (crash_looping/liveness_probe_bound fields), crash → re-fork. Co-Authored-By: Claude Opus 4.8 * UN-3606 [FIX] Address SonarCloud — reliability + maintainability nits - test: avoid float equality (math.isinf for the crash-loop freshness; drop the 0.0 literal) — clears the only New-Code reliability bug (Rating C → A). - supervisor: clean the `# noqa: ANN201` suppression syntax (explanation moved to the docstring); narrow the child-failure catch BaseException → Exception (the realistic startup-failure surface; SystemExit/KeyboardInterrupt would exit anyway); logger.error(exc_info=True) → logger.exception() on the os.fork() guard. Co-Authored-By: Claude Opus 4.8 * UN-3606 [FIX] Address greptile — snapshot crash dict in is_crash_looping is_crash_looping() runs in the LivenessServer daemon thread (via freshness()) while the main thread mutates _consecutive_crashes in schedule_restart() — a bare .values() iteration could raise "dictionary changed size during iteration". Snapshot with tuple(...) (atomic under the GIL) before iterating. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- docker/docker-compose.yaml | 17 + workers/pg_queue_consumer/__main__.py | 27 +- workers/pg_queue_consumer/_bootstrap.py | 24 + workers/pg_queue_consumer/supervisor.py | 485 +++++++++++++++++++ workers/queue_backend/pg_queue/consumer.py | 69 ++- workers/sample.env | 9 + workers/tests/test_pg_consumer_supervisor.py | 276 +++++++++++ 7 files changed, 873 insertions(+), 34 deletions(-) create mode 100644 workers/pg_queue_consumer/_bootstrap.py create mode 100644 workers/pg_queue_consumer/supervisor.py create mode 100644 workers/tests/test_pg_consumer_supervisor.py diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index f58f5a9761..a8f64a00e9 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -638,6 +638,17 @@ services: - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=file_processing - WORKER_PG_QUEUE_CONSUMER_QUEUE=file_processing,api_file_processing - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + # UN-3606: prefork N consumer children so file batches run in parallel (the + # PG analogue of the Celery worker's --concurrency). SKIP LOCKED distributes + # batches across children + replicas; a single execution is still capped by + # MAX_PARALLEL_FILE_BATCHES. Mirrors WORKER_FILE_PROCESSING_CONCURRENCY. + - WORKER_PG_QUEUE_CONSUMER_CONCURRENCY=${PG_FILEPROC_CONCURRENCY:-4} + # process_file_batch blocks on the executor RPC (up to EXECUTOR_RESULT_TIMEOUT, + # default 3600s). vt must exceed ONE batch's worst case (children run in + # parallel, not serially) or a long batch gets re-claimed mid-run; health-stale + # sits just above vt so a legitimately-long batch never trips the fleet probe. + - WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=${WORKER_PG_FILEPROC_VT_SECONDS:-3660} + - WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS=${WORKER_PG_FILEPROC_HEALTH_STALE_SECONDS:-3720} labels: - traefik.enable=false volumes: @@ -743,6 +754,12 @@ services: # double-spend, two runs racing the same reply_key) or killed by the probe. - WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=${WORKER_PG_EXECUTOR_VT_SECONDS:-3660} - WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS=${WORKER_PG_EXECUTOR_HEALTH_STALE_SECONDS:-3720} + # UN-3606: prefork N executor children so concurrent file batches' executor + # RPCs run in parallel instead of serializing here. Without this the + # file-processing parallelism delivers no end-to-end speedup — the LLM + # extraction (the wall-clock-dominant work) is the bottleneck. Match the + # file-processing concurrency so N parallel files get N parallel executors. + - WORKER_PG_QUEUE_CONSUMER_CONCURRENCY=${PG_EXECUTOR_CONCURRENCY:-4} - CELERY_QUEUES_EXECUTOR=${CELERY_QUEUES_EXECUTOR:-celery_executor_legacy,celery_executor_agentic,celery_executor_agentic_table} labels: - traefik.enable=false diff --git a/workers/pg_queue_consumer/__main__.py b/workers/pg_queue_consumer/__main__.py index 1be588708b..e49aee7191 100644 --- a/workers/pg_queue_consumer/__main__.py +++ b/workers/pg_queue_consumer/__main__.py @@ -20,21 +20,32 @@ Launch via ``python -m pg_queue_consumer`` or ``./run-worker.sh pg-queue-consumer``. """ -import os - def _bootstrap_and_run() -> None: - # Select the source worker whose tasks back this consumer's queue. Must run - # BEFORE `import worker`, which reads WORKER_TYPE at import time. We overwrite - # (not setdefault) because the launcher's own WORKER_TYPE owns no tasks. + # CONCURRENCY > 1 → run a prefork supervisor (UN-3606): N isolated consumer + # children, the PG analogue of Celery --pool=prefork --concurrency=N. The + # supervisor forks the children (each does its OWN worker bootstrap, so no + # connections are inherited across the fork) and owns the single health port. + # CONCURRENCY = 1 (default) keeps the plain single-process path below, + # byte-identical to before the supervisor existed. # # Kept inside this guarded function (not at module scope) so the env mutation # and the heavy `import worker` bootstrap only happen on the `python -m` # entry — never as a side-effect if this module is imported by a test, IDE, # or type-checker walking `__main__` files. - os.environ["WORKER_TYPE"] = os.environ.get( - "WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE", "notification" - ) + from pg_queue_consumer.supervisor import concurrency_from_env, run_supervised + + concurrency = concurrency_from_env() + if concurrency > 1: + run_supervised(concurrency) + return + + # Single-process path: select the source worker whose tasks back this + # consumer's queue (must run BEFORE `import worker`, which reads WORKER_TYPE at + # import time). Shared with the supervisor's children via _bootstrap. + from pg_queue_consumer._bootstrap import select_source_worker_type + + select_source_worker_type() import worker # noqa: F401 — side-effect: registers the source worker's tasks from queue_backend.pg_queue.consumer import main diff --git a/workers/pg_queue_consumer/_bootstrap.py b/workers/pg_queue_consumer/_bootstrap.py new file mode 100644 index 0000000000..a4994072a5 --- /dev/null +++ b/workers/pg_queue_consumer/_bootstrap.py @@ -0,0 +1,24 @@ +"""Shared bootstrap for the PG-queue consumer entrypoints. + +Both the single-process launcher (``__main__``) and the prefork supervisor's +children must select the source worker type *before* ``import worker`` (which +reads ``WORKER_TYPE`` at import time to register that type's tasks). Kept in a +neutral module — not ``__main__`` — so ``supervisor`` can import it without +triggering the ``python -m`` entry. +""" + +import os + + +def select_source_worker_type() -> None: + """Point ``WORKER_TYPE`` at the source worker whose tasks back this consumer. + + Overwrites (not ``setdefault``): the launcher's own ``WORKER_TYPE`` is the + ``pg_queue_consumer`` pseudo-type, which owns no tasks — a plain ``import + worker`` would fall back to the ``general`` worker and drop every message as an + unknown task. Default ``notification`` (the worker owning the first migrated + leaf task). Must run before ``import worker``. + """ + os.environ["WORKER_TYPE"] = os.environ.get( + "WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE", "notification" + ) diff --git a/workers/pg_queue_consumer/supervisor.py b/workers/pg_queue_consumer/supervisor.py new file mode 100644 index 0000000000..cc99ef091b --- /dev/null +++ b/workers/pg_queue_consumer/supervisor.py @@ -0,0 +1,485 @@ +"""Prefork supervisor for the PG-queue consumer (UN-3606). + +Forks ``WORKER_PG_QUEUE_CONSUMER_CONCURRENCY`` copies of the single-threaded +:class:`~queue_backend.pg_queue.consumer.PgQueueConsumer` so multiple file +batches run in parallel — the PG analogue of Celery's ``--pool=prefork +--concurrency=N``. ``SELECT … FOR UPDATE SKIP LOCKED`` distributes work across the +children (and across replicas): each child claims distinct rows, a single +execution is still capped by ``MAX_PARALLEL_FILE_BATCHES``, and total live +parallelism = ``concurrency × replicas`` (k8s HPA scales the replica count). + +**Process model** (matches Celery prefork — the cloud-trusted choice): each child +is a fully isolated process with its own DB connections and thread-local +``StateStore`` — no shared mutable state, no thread-safety surface. A child crash +is isolated and re-forked (rate-limited); its in-flight message redelivers via +``vt`` (at-least-once). The **consumer code is unchanged** — concurrency is purely +a launch concern. ``CONCURRENCY = 1`` keeps the plain single-process ``main()`` +path (byte-identical to before this module existed). + +**Health**: the supervisor owns the single liveness port and reports the *fleet's* +freshness — the staleness of the oldest-polling child (each child publishes its +last-poll wall-time into a shared array). A child that dies is re-forked +internally (transient); a child that **crash-loops** (dies immediately N times in +a row, never reaching a real poll) forces the probe to 503 so k8s restarts the +pod rather than the supervisor masking a wedged fleet with fresh-looking re-forks. + +**Fork safety**: the initial fleet is forked while the parent is single-threaded. +Re-forks happen after the liveness daemon thread exists; the only other thread is +that probe (idle in ``select`` between requests, and CPython 3.12 re-inits the +``logging`` locks across ``fork`` via ``os.register_at_fork``), and each child +resets inherited signal handlers + does its own ``import worker`` before touching +shared resources — so an inherited held lock or the parent's ``_on_term`` cannot +wedge or mis-signal a child. +""" + +from __future__ import annotations + +import contextlib +import logging +import multiprocessing +import os +import signal +import threading +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from queue_backend.pg_queue.liveness import LivenessServer + +logger = logging.getLogger(__name__) + +_DEFAULT_CONCURRENCY = 1 +# Fork-bomb backstop for a fat-fingered env (a single machine can't usefully run +# hundreds of heavy file-processing children anyway — scale replicas instead). +_MAX_CONCURRENCY = 64 +# How often each child republishes its heartbeat, and the parent reaps + checks. +_REPORT_INTERVAL_SECONDS = 1.0 +_MONITOR_INTERVAL_SECONDS = 1.0 +# Re-fork backoff floor and ceiling — a crash-looping child must not fork-storm. +_RESTART_MIN_INTERVAL_SECONDS = 2.0 +_RESTART_MAX_BACKOFF_SECONDS = 30.0 +# A child that stays up at least this long before exiting is a normal exit, not an +# immediate crash — it resets the slot's consecutive-crash counter. +_MIN_HEALTHY_UPTIME_SECONDS = 10.0 +# Consecutive immediate crashes after which the fleet probe is forced unhealthy. +_CRASH_LOOP_THRESHOLD = 3 +# How long to wait per child for a graceful drain on shutdown before SIGKILL. +_SHUTDOWN_GRACE_SECONDS = 30.0 + + +def concurrency_from_env() -> int: + """Parse ``WORKER_PG_QUEUE_CONSUMER_CONCURRENCY`` (default 1, clamped to a sane + max). 1 → the single-process path; >1 → the prefork supervisor. + """ + raw = os.environ.get("WORKER_PG_QUEUE_CONSUMER_CONCURRENCY") + if raw is None or raw == "": + return _DEFAULT_CONCURRENCY + try: + n = int(raw) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Invalid WORKER_PG_QUEUE_CONSUMER_CONCURRENCY={raw!r}: {exc}" + ) from exc + if n < 1: + raise ValueError(f"WORKER_PG_QUEUE_CONSUMER_CONCURRENCY must be >= 1, got {n}") + if n > _MAX_CONCURRENCY: + logger.warning( + "WORKER_PG_QUEUE_CONSUMER_CONCURRENCY=%s exceeds the %s cap; clamping " + "(scale replicas for more parallelism, not one fat process)", + n, + _MAX_CONCURRENCY, + ) + n = _MAX_CONCURRENCY + return n + + +class _Fleet: + """Owns the per-slot child state — pid, last-fork, heartbeat, crash count and + pending-restart schedule — keeping them mutually consistent. Slots are + validated against ``[0, concurrency)`` so a stray key can't silently desync + the structures or ``IndexError`` the shared array. + """ + + def __init__(self, concurrency: int) -> None: + self._n = concurrency + # Shared, fork-inherited heartbeat slots (one last-poll wall-time per + # child). lock=False is safe: a slot is written either by the parent + # (seed, at construction, while no child owns it) OR by that child's + # heartbeat thread — never concurrently — and only read by the parent, so + # a torn double read just yields one stale sample that self-corrects. + self._heartbeats = multiprocessing.Array("d", concurrency, lock=False) + now = time.time() + for i in range(concurrency): + self._heartbeats[i] = now + self._pids: dict[int, int] = {} + self._last_fork: dict[int, float] = {} + self._consecutive_crashes: dict[int, int] = {} + self._restart_due: dict[int, float] = {} # slot -> monotonic not-before + + @property + def concurrency(self) -> int: + return self._n + + @property + def heartbeats(self): # noqa: ANN201 + """The shared heartbeat array (a ctypes array, passed to forked children, + which write their own slot directly). + """ + return self._heartbeats + + def _validate(self, slot: int) -> None: + if not 0 <= slot < self._n: + raise IndexError(f"slot {slot} out of range [0, {self._n})") + + def record_fork(self, slot: int, pid: int) -> None: + """Mark ``slot`` alive under ``pid``; clears any pending restart. Note the + heartbeat is deliberately NOT reseeded here — a re-forked child must earn + freshness by actually polling, so a crash-looping slot ages instead of + looking perpetually fresh. + """ + self._validate(slot) + self._pids[slot] = pid + self._last_fork[slot] = time.monotonic() + self._restart_due.pop(slot, None) + + def reap(self, slot: int) -> float: + """Drop the slot's pid + last-fork together; return the child's uptime (s).""" + forked_at = self._last_fork.pop(slot, time.monotonic()) + self._pids.pop(slot, None) + return time.monotonic() - forked_at + + def schedule_restart(self, slot: int, uptime: float) -> int: + """Record the exit and set the re-fork not-before; return the consecutive + immediate-crash count. A child that ran healthily before exiting resets the + counter; an immediate death increments it and backs the restart off + (capped) so a crash loop can't fork-storm. + """ + if uptime < _MIN_HEALTHY_UPTIME_SECONDS: + n = self._consecutive_crashes.get(slot, 0) + 1 + else: + n = 0 # ran fine, then exited — not a crash loop + self._consecutive_crashes[slot] = n + backoff = min( + _RESTART_MIN_INTERVAL_SECONDS * max(1, n), _RESTART_MAX_BACKOFF_SECONDS + ) + self._restart_due[slot] = time.monotonic() + backoff + return n + + def due_restarts(self) -> list[int]: + """Slots whose re-fork backoff has elapsed (oldest schedule first).""" + now = time.monotonic() + return sorted(s for s, due in self._restart_due.items() if due <= now) + + def consecutive_crashes(self, slot: int) -> int: + return self._consecutive_crashes.get(slot, 0) + + def alive_items(self) -> list[tuple[int, int]]: + return list(self._pids.items()) + + def alive_count(self) -> int: + return len(self._pids) + + def is_crash_looping(self) -> bool: + """True if any slot has died immediately ``_CRASH_LOOP_THRESHOLD`` times in + a row — the signal that the heartbeat alone can't be trusted fresh. + + Snapshots the values first (``tuple(...)`` is atomic under the GIL): this + runs in the liveness daemon thread (via :meth:`freshness`) while the main + thread mutates ``_consecutive_crashes`` in :meth:`schedule_restart`, so a + bare ``.values()`` iteration could raise "dictionary changed size during + iteration". + """ + return any( + n >= _CRASH_LOOP_THRESHOLD for n in tuple(self._consecutive_crashes.values()) + ) + + def oldest_age(self) -> float: + now = time.time() + return max((now - hb for hb in self._heartbeats), default=0.0) + + def freshness(self) -> float: + """Liveness verdict source: a crash-looping fleet is force-stale (``inf``) + so the probe trips 503 even if a just-constructed child briefly looked + fresh; otherwise the oldest child's staleness (catches a wedged-alive + child). + """ + return float("inf") if self.is_crash_looping() else self.oldest_age() + + +def _run_child(slot: int, heartbeats) -> None: # noqa: ANN001 (ctypes array) + """Build one consumer and run it forever, publishing its heartbeat. + + The worker import (and any connections it opens) happens HERE, in the child — + never inherited across the fork — so each process owns its own connections. + A *guarded* daemon thread publishes the consumer's last-poll wall-time into + ``heartbeats[slot]`` for the supervisor's fleet liveness. + """ + from pg_queue_consumer._bootstrap import select_source_worker_type + + select_source_worker_type() # set WORKER_TYPE before importing worker + import worker # noqa: F401 — side-effect: registers the source worker's tasks + from queue_backend.pg_queue.consumer import build_consumer_from_env + + consumer = build_consumer_from_env() + + def _publish_heartbeat() -> None: + # last-poll wall-time = now − (seconds since last poll). Frozen while a + # task runs (the consumer stamps its heartbeat at the top of poll_once), + # so a child stuck on a too-long task goes stale exactly as the single + # consumer does. Guarded so a transient error (e.g. teardown during + # shutdown) logs loudly and the loop continues instead of dying silently + # and false-staling a healthy child. + while True: + try: + heartbeats[slot] = time.time() - consumer.seconds_since_last_poll() + except Exception: + logger.exception( + "PG-queue consumer: heartbeat publish failed for slot=%s", slot + ) + time.sleep(_REPORT_INTERVAL_SECONDS) + + threading.Thread(target=_publish_heartbeat, daemon=True, name=f"pg-hb-{slot}").start() + # consumer.run() installs its own SIGTERM/SIGINT handlers → graceful stop. + consumer.run() + + +def _child_after_fork(slot: int, heartbeats) -> None: # noqa: ANN001 (ctypes array) + """Child side of the fork: reset inherited state, run, hard-exit on failure. + + Resets the supervisor's signal handlers to ``SIG_DFL`` *immediately* — until + ``consumer.run()`` installs its own, a SIGTERM arriving in the fork→run window + (which spans the slow ``import worker`` bootstrap) must NOT fire the parent's + ``_on_term`` closure in the child (it captured a stale ``children`` dict and + would signal sibling pids). ``SIG_DFL`` = terminate, the correct disposition + for a not-yet-running child. + """ + signal.signal(signal.SIGTERM, signal.SIG_DFL) + signal.signal(signal.SIGINT, signal.SIG_DFL) + try: + _run_child(slot, heartbeats) + except Exception: + # A child that can't even start must not return into the supervisor loop + # (it would fork grandchildren). Log + hard exit. Exception (not + # BaseException) is the realistic startup-failure surface here — import, + # connection, config; SystemExit/KeyboardInterrupt would exit anyway. + logger.exception("PG-queue consumer: child slot=%s failed to run", slot) + os._exit(1) + os._exit(0) + + +def _try_fork_child(fleet: _Fleet, slot: int) -> bool: + """Fork one child for ``slot``. Returns False (without raising) if ``os.fork`` + fails — EAGAIN (RLIMIT_NPROC) / ENOMEM are realistic under heavy-child load — + so the caller can fail fast (initial fleet) or leave the slot for the next + monitor tick (re-fork path) instead of an uncaught crash taking the fleet down. + """ + try: + pid = os.fork() + except OSError: + logger.exception( + "PG-queue consumer: os.fork() failed for slot=%s (process/memory " + "limit?) — will retry", + slot, + ) + return False + if pid == 0: # child — never returns + _child_after_fork(slot, fleet.heartbeats) + fleet.record_fork(slot, pid) + logger.info("PG-queue consumer: forked child slot=%s pid=%s", slot, pid) + return True + + +def _reap_dead(fleet: _Fleet, stopping: threading.Event) -> None: + """Reap exited children and schedule their re-fork (unless shutting down).""" + for slot, pid in fleet.alive_items(): + try: + reaped, _status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + reaped = pid # already reaped elsewhere — treat as gone + if reaped == 0: + continue # still alive + uptime = fleet.reap(slot) + if stopping.is_set(): + continue # do not resurrect during shutdown + crashes = fleet.schedule_restart(slot, uptime) + level = logging.ERROR if crashes >= _CRASH_LOOP_THRESHOLD else logging.WARNING + logger.log( + level, + "PG-queue consumer: child slot=%s pid=%s exited after %.1fs " + "(consecutive immediate crashes=%s) — re-fork scheduled", + slot, + pid, + uptime, + crashes, + ) + + +def _restart_due_children(fleet: _Fleet, stopping: threading.Event) -> None: + """Re-fork the slots whose backoff has elapsed — non-blocking (the backoff is + a scheduled not-before, not an in-loop sleep), and re-checking ``stopping`` + each iteration so a SIGTERM mid-cycle can't spawn a fresh child into shutdown. + """ + for slot in fleet.due_restarts(): + if stopping.is_set(): + return + # On success record_fork clears the pending restart; on fork failure the + # slot stays due and is retried next tick. + _try_fork_child(fleet, slot) + + +def run_supervised(concurrency: int) -> None: + """Fork ``concurrency`` consumer children and supervise them until SIGTERM.""" + logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) + + fleet = _Fleet(concurrency) + stopping = threading.Event() + + def _signal_children(sig: int) -> None: + for _slot, pid in fleet.alive_items(): + with contextlib.suppress(ProcessLookupError): + os.kill(pid, sig) + + def _on_term(signum: int, _frame: object) -> None: + logger.info( + "PG-queue consumer supervisor: signal %s — stopping %d child(ren)", + signum, + fleet.alive_count(), + ) + stopping.set() + _signal_children(signal.SIGTERM) + + signal.signal(signal.SIGTERM, _on_term) + signal.signal(signal.SIGINT, _on_term) + + # Fork the initial fleet while single-threaded (before the liveness thread). + # A fork failure here is fatal + actionable rather than a half-started fleet. + for slot in range(concurrency): + if not _try_fork_child(fleet, slot): + stopping.set() + _signal_children(signal.SIGTERM) + _join_children(fleet, _SHUTDOWN_GRACE_SECONDS) + raise RuntimeError( + f"PG-queue consumer: os.fork() failed starting child {slot}/" + f"{concurrency} — reduce WORKER_PG_QUEUE_CONSUMER_CONCURRENCY or " + "raise the process/memory limit" + ) + + health = _maybe_start_supervisor_health(fleet) + try: + while not stopping.is_set(): + _reap_dead(fleet, stopping) + _restart_due_children(fleet, stopping) + stopping.wait(_MONITOR_INTERVAL_SECONDS) # responsive to SIGTERM + finally: + stopping.set() + _signal_children(signal.SIGTERM) + _join_children(fleet, _SHUTDOWN_GRACE_SECONDS) + if health is not None: + health.stop() + logger.info("PG-queue consumer supervisor: stopped") + + +def _wait_for_exit(pid: int, deadline: float) -> bool: + """Poll ``pid`` until it exits or ``deadline`` (monotonic) passes. True if it + exited (or was already reaped). + """ + while time.monotonic() < deadline: + try: + reaped, _status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return True + if reaped != 0: + return True + time.sleep(0.1) + return False + + +def _join_children(fleet: _Fleet, grace_seconds: float) -> None: + """Wait up to ``grace_seconds`` *per child* for a graceful drain; SIGKILL + + reap any straggler. The budget is per-child (not a single shared deadline) so + one slow-draining child can't starve the others of their grace. + """ + for slot, pid in fleet.alive_items(): + if _wait_for_exit(pid, time.monotonic() + grace_seconds): + continue + logger.warning( + "PG-queue consumer: child slot=%s pid=%s did not stop in %ss — SIGKILL", + slot, + pid, + grace_seconds, + ) + with contextlib.suppress(ProcessLookupError): + os.kill(pid, signal.SIGKILL) + with contextlib.suppress(ChildProcessError): + os.waitpid(pid, 0) + + +def _maybe_start_supervisor_health(fleet: _Fleet) -> LivenessServer | None: + """Start the fleet liveness server when a port is configured; else None. + + Reuses the single-process consumer's env knobs (``..._HEALTH_PORT`` / + ``..._HEALTH_STALE_SECONDS``) and the same HTTP contract (``/health`` → + 200/503), so the k8s probe config is unchanged. The JSON body differs + (``check="pg_queue_fleet"``, age key ``oldest_child_seconds_since_poll``) since + the freshness source is the fleet's oldest child, not one poll loop. + + A bind failure does not abort the consumer (it must keep draining the queue), + but ``EADDRINUSE`` usually signals a real config bug, so it's logged at error; + either way ``liveness_probe_bound: false`` is surfaced in the status payload so + the degradation is observable. + """ + from queue_backend.pg_queue.consumer import ( + _DEFAULT_HEALTH_STALE_SECONDS, + consumer_env, + ) + from queue_backend.pg_queue.liveness import LivenessServer + + port: int | None = consumer_env("HEALTH_PORT", None, int) + if port is None: + logger.info("PG-queue consumer supervisor: HEALTH_PORT unset — liveness disabled") + return None + stale_after = consumer_env( + "HEALTH_STALE_SECONDS", _DEFAULT_HEALTH_STALE_SECONDS, float + ) + + def _extra_status() -> dict[str, object]: + return { + "alive_children": fleet.alive_count(), + "concurrency": fleet.concurrency, + "crash_looping": fleet.is_crash_looping(), + "liveness_probe_bound": True, + } + + server = LivenessServer( + freshness_fn=fleet.freshness, + stale_after=stale_after, + port=port, + check_name="pg_queue_fleet", + age_key="oldest_child_seconds_since_poll", + extra_status_fn=_extra_status, + thread_name="pg-supervisor-liveness", + log_label="pg-queue supervisor", + ) + try: + server.start() + except OSError as exc: + import errno + + level = logging.ERROR if exc.errno == errno.EADDRINUSE else logging.WARNING + logger.log( + level, + "PG-queue consumer supervisor: liveness could not bind :%s (%s) — " + "continuing WITHOUT a probe", + port, + exc.strerror or exc, + exc_info=True, + ) + return None + logger.info( + "PG-queue consumer supervisor: fleet liveness on :%s/health (stale after " + "%ss, %d children)", + server.bound_port, + stale_after, + fleet.concurrency, + ) + return server diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index 50af3efea7..12081fd647 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -427,36 +427,53 @@ def _parse_queue_list(raw: str) -> list[str]: return queues -def main() -> None: - logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) +def consumer_env(suffix: str, default: _T, cast: Callable[[str], _T]) -> _T: + """Read ``WORKER_PG_QUEUE_CONSUMER_`` with a typed default. + + Preserves the default's type through to PgQueueConsumer's typed ``__init__`` + (a bare ``type`` would erase it). On a bad value, fail with the offending var + name instead of a context-free ``int('abc')`` ValueError. Treats empty-string + as unset (an empty HEALTH_PORT must hit the clean opt-out, not ``int("")``). + Module-level (not nested in ``main``) so the prefork supervisor reads the same + knobs the single-process path does. + """ + var = f"WORKER_PG_QUEUE_CONSUMER_{suffix}" + raw = os.getenv(var) + if raw is None or raw == "": + return default + try: + return cast(raw) + except (ValueError, TypeError) as exc: + raise ValueError(f"Invalid {var}={raw!r}: {exc}") from exc - def _env(suffix: str, default: _T, cast: Callable[[str], _T]) -> _T: - # Preserve the default's type through to PgQueueConsumer's typed - # __init__ (a bare `type` would erase it). On a bad value, fail with the - # offending var name instead of a context-free `int('abc')` ValueError. - var = f"WORKER_PG_QUEUE_CONSUMER_{suffix}" - raw = os.getenv(var) - # Treat empty-string as unset: an empty HEALTH_PORT (e.g. a run-worker.sh - # fallback resolving empty) must hit the clean opt-out, not int("") crash. - if raw is None or raw == "": - return default - try: - return cast(raw) - except (ValueError, TypeError) as exc: - raise ValueError(f"Invalid {var}={raw!r}: {exc}") from exc - - consumer = PgQueueConsumer( - queue_names=_env("QUEUE", [_DEFAULT_QUEUE], _parse_queue_list), - batch_size=_env("BATCH", _DEFAULT_BATCH, int), - vt_seconds=_env("VT_SECONDS", _DEFAULT_VT_SECONDS, int), - poll_interval=_env("POLL_INTERVAL", _DEFAULT_POLL_INTERVAL, float), - backoff_max=_env("BACKOFF_MAX", _DEFAULT_BACKOFF_MAX, float), - max_attempts=_env("MAX_ATTEMPTS", _DEFAULT_MAX_ATTEMPTS, int), + +def build_consumer_from_env() -> PgQueueConsumer: + """Construct a :class:`PgQueueConsumer` from the ``WORKER_PG_QUEUE_CONSUMER_*`` + env. Shared by ``main`` (single process) and the prefork supervisor's children, + so every consumer instance is configured identically. + """ + return PgQueueConsumer( + queue_names=consumer_env("QUEUE", [_DEFAULT_QUEUE], _parse_queue_list), + batch_size=consumer_env("BATCH", _DEFAULT_BATCH, int), + vt_seconds=consumer_env("VT_SECONDS", _DEFAULT_VT_SECONDS, int), + poll_interval=consumer_env("POLL_INTERVAL", _DEFAULT_POLL_INTERVAL, float), + backoff_max=consumer_env("BACKOFF_MAX", _DEFAULT_BACKOFF_MAX, float), + max_attempts=consumer_env("MAX_ATTEMPTS", _DEFAULT_MAX_ATTEMPTS, int), ) + + +def main() -> None: + logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) + consumer = build_consumer_from_env() + # Annotate the int|None result so the intent ("a port, or None when unset") is + # declared rather than recovered from the generic widening over default=None. + port: int | None = consumer_env("HEALTH_PORT", None, int) health_server = _maybe_start_health_server( consumer, - port=_env("HEALTH_PORT", None, int), - stale_after=_env("HEALTH_STALE_SECONDS", _DEFAULT_HEALTH_STALE_SECONDS, float), + port=port, + stale_after=consumer_env( + "HEALTH_STALE_SECONDS", _DEFAULT_HEALTH_STALE_SECONDS, float + ), ) try: consumer.run() diff --git a/workers/sample.env b/workers/sample.env index 0cf9fb6b2a..d5564536ce 100644 --- a/workers/sample.env +++ b/workers/sample.env @@ -481,3 +481,12 @@ PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python # `pg_queue_enabled` on. Keep False until the worker-pg-executor consumer is # running in the fleet; set False for instant rollback. PG_QUEUE_TRANSPORT_ENABLED=False + +# PG-queue consumer prefork concurrency (UN-3606). >1 makes the consumer launcher +# fork N isolated consumer children — the PG analogue of Celery's prefork +# --concurrency, so file batches run in parallel. SKIP LOCKED distributes work +# across children AND replicas; a single execution is still capped by +# MAX_PARALLEL_FILE_BATCHES, and total live parallelism = concurrency x replicas +# (k8s HPA scales replicas). 1 = single process (default). Set per consumer; for +# the file-processing consumer it mirrors WORKER_FILE_PROCESSING_CONCURRENCY. +# WORKER_PG_QUEUE_CONSUMER_CONCURRENCY=1 diff --git a/workers/tests/test_pg_consumer_supervisor.py b/workers/tests/test_pg_consumer_supervisor.py new file mode 100644 index 0000000000..c5a1912ca7 --- /dev/null +++ b/workers/tests/test_pg_consumer_supervisor.py @@ -0,0 +1,276 @@ +"""Tests for the PG-queue consumer prefork supervisor (UN-3606). + +DB-free / fork-free: the env knob, the ``_Fleet`` bookkeeping, the reap/restart +scheduling, the join+SIGKILL escalation and the fork/health guards are exercised in +isolation (``os.*`` mocked) so the suite stays fast and deterministic — no real +children, no worker bootstrap, no signals installed. +""" + +import errno +import threading +import time +from unittest.mock import MagicMock, patch + +import pytest + +from pg_queue_consumer import supervisor as sup +from pg_queue_consumer.supervisor import ( + _CRASH_LOOP_THRESHOLD, + _MAX_CONCURRENCY, + _MIN_HEALTHY_UPTIME_SECONDS, + _Fleet, + _child_after_fork, + _join_children, + _reap_dead, + _restart_due_children, + _try_fork_child, + _wait_for_exit, + concurrency_from_env, +) + +_MOD = "pg_queue_consumer.supervisor" +_ENV = "WORKER_PG_QUEUE_CONSUMER_CONCURRENCY" + + +class TestConcurrencyFromEnv: + def test_unset_defaults_to_one(self, monkeypatch): + monkeypatch.delenv(_ENV, raising=False) + assert concurrency_from_env() == 1 + + def test_empty_defaults_to_one(self, monkeypatch): + monkeypatch.setenv(_ENV, "") + assert concurrency_from_env() == 1 + + def test_valid_value(self, monkeypatch): + monkeypatch.setenv(_ENV, "4") + assert concurrency_from_env() == 4 + + def test_clamped_to_max(self, monkeypatch): + monkeypatch.setenv(_ENV, str(_MAX_CONCURRENCY + 50)) + assert concurrency_from_env() == _MAX_CONCURRENCY + + @pytest.mark.parametrize("bad", ["0", "-3"]) + def test_below_one_raises(self, monkeypatch, bad): + monkeypatch.setenv(_ENV, bad) + with pytest.raises(ValueError, match=">= 1"): + concurrency_from_env() + + def test_non_int_raises(self, monkeypatch): + monkeypatch.setenv(_ENV, "abc") + with pytest.raises(ValueError, match="Invalid"): + concurrency_from_env() + + +class TestFleet: + def test_record_and_reap_keep_structures_consistent(self): + f = _Fleet(2) + f.record_fork(0, 111) + assert f.alive_items() == [(0, 111)] and f.alive_count() == 1 + uptime = f.reap(0) + assert uptime >= 0 # a non-negative monotonic duration + assert f.alive_count() == 0 # pid + last_fork dropped together + + def test_slot_out_of_range_raises(self): + f = _Fleet(2) + with pytest.raises(IndexError): + f.record_fork(5, 111) + + def test_record_fork_does_not_reseed_heartbeat(self): + # The crash-loop fix: a re-fork must NOT refresh the slot, or a child that + # never polls looks perpetually fresh. + f = _Fleet(1) + f._heartbeats[0] = time.time() - 500 # an aged slot + f.record_fork(0, 111) + assert f.oldest_age() > 400 # still aged, not reset to ~0 + + def test_immediate_crash_increments_then_loops(self): + f = _Fleet(1) + for i in range(1, _CRASH_LOOP_THRESHOLD + 1): + n = f.schedule_restart(0, uptime=0.1) # died immediately + assert n == i + assert f.is_crash_looping() is True + + def test_healthy_uptime_resets_crash_counter(self): + f = _Fleet(1) + f.schedule_restart(0, uptime=0.1) + f.schedule_restart(0, uptime=0.1) + assert f.consecutive_crashes(0) == 2 + n = f.schedule_restart(0, uptime=_MIN_HEALTHY_UPTIME_SECONDS + 1) + assert n == 0 and f.is_crash_looping() is False + + def test_freshness_is_inf_when_crash_looping(self): + import math + + f = _Fleet(1) + for _ in range(_CRASH_LOOP_THRESHOLD): + f.schedule_restart(0, uptime=0.1) + assert math.isinf(f.freshness()) + + def test_freshness_is_oldest_age_when_healthy(self): + f = _Fleet(2) + f._heartbeats[1] = time.time() - 100 + assert 99 < f.freshness() < 102 + + def test_due_restarts_respects_backoff(self, monkeypatch): + f = _Fleet(1) + clock = [1000.0] + monkeypatch.setattr(f"{_MOD}.time.monotonic", lambda: clock[0]) + f.schedule_restart(0, uptime=0.1) # backoff scheduled in the future + assert f.due_restarts() == [] # not yet due + clock[0] += 100.0 # well past any backoff + assert f.due_restarts() == [0] + + +class TestReapDead: + def test_dead_child_reaped_and_rescheduled(self): + f = _Fleet(1) + f.record_fork(0, 111) + stopping = threading.Event() + with patch(f"{_MOD}.os.waitpid", return_value=(111, 0)): # exited + _reap_dead(f, stopping) + assert f.alive_count() == 0 + assert f.due_restarts() in ([], [0]) # scheduled (maybe backed off) + assert f.consecutive_crashes(0) >= 1 # counted (immediate death) + + def test_live_child_left_alone(self): + f = _Fleet(1) + f.record_fork(0, 111) + with patch(f"{_MOD}.os.waitpid", return_value=(0, 0)): # alive + _reap_dead(f, threading.Event()) + assert f.alive_items() == [(0, 111)] + + def test_not_rescheduled_while_stopping(self): + f = _Fleet(1) + f.record_fork(0, 111) + stopping = threading.Event() + stopping.set() + with patch(f"{_MOD}.os.waitpid", return_value=(111, 0)): + _reap_dead(f, stopping) + assert f.alive_count() == 0 # reaped + assert f.due_restarts() == [] # but NOT scheduled for restart + + def test_already_reaped_is_treated_gone(self): + f = _Fleet(1) + f.record_fork(0, 111) + with patch(f"{_MOD}.os.waitpid", side_effect=ChildProcessError()): + _reap_dead(f, threading.Event()) + assert f.alive_count() == 0 + + +class TestRestartDueChildren: + def test_due_slot_is_reforked(self): + f = _Fleet(1) + f.schedule_restart(0, uptime=0.1) + with ( + patch(f"{_MOD}.time.monotonic", return_value=1e9), # everything due + patch(f"{_MOD}._try_fork_child") as fork, + ): + _restart_due_children(f, threading.Event()) + fork.assert_called_once_with(f, 0) + + def test_stopping_blocks_refork(self): + f = _Fleet(1) + f.schedule_restart(0, uptime=0.1) + stopping = threading.Event() + stopping.set() + with ( + patch(f"{_MOD}.time.monotonic", return_value=1e9), + patch(f"{_MOD}._try_fork_child") as fork, + ): + _restart_due_children(f, stopping) + fork.assert_not_called() # no fresh child spawned into shutdown + + +class TestTryForkChild: + def test_fork_oserror_returns_false_and_does_not_record(self): + f = _Fleet(1) + with patch(f"{_MOD}.os.fork", side_effect=OSError(errno.EAGAIN, "again")): + assert _try_fork_child(f, 0) is False + assert f.alive_count() == 0 # slot left for the next monitor tick + + def test_parent_records_child(self): + f = _Fleet(1) + with patch(f"{_MOD}.os.fork", return_value=222): # parent sees child pid + assert _try_fork_child(f, 0) is True + assert f.alive_items() == [(0, 222)] + + +class TestChildAfterFork: + def test_resets_signals_and_exits_zero_on_clean_run(self): + with ( + patch(f"{_MOD}.signal.signal") as sig, + patch(f"{_MOD}._run_child"), + patch(f"{_MOD}.os._exit", side_effect=SystemExit) as exit_, + ): + with pytest.raises(SystemExit): + _child_after_fork(0, MagicMock()) + # SIGTERM + SIGINT reset to default before running. + assert sig.call_count == 2 + exit_.assert_called_once_with(0) + + def test_hard_exits_one_when_run_raises(self): + with ( + patch(f"{_MOD}.signal.signal"), + patch(f"{_MOD}._run_child", side_effect=RuntimeError("boom")), + patch(f"{_MOD}.os._exit", side_effect=SystemExit) as exit_, + ): + with pytest.raises(SystemExit): + _child_after_fork(0, MagicMock()) + exit_.assert_called_once_with(1) + + +class TestWaitForExit: + def test_true_when_child_exits(self): + with patch(f"{_MOD}.os.waitpid", return_value=(111, 0)): + assert _wait_for_exit(111, time.monotonic() + 5) is True + + def test_true_when_already_reaped(self): + with patch(f"{_MOD}.os.waitpid", side_effect=ChildProcessError()): + assert _wait_for_exit(111, time.monotonic() + 5) is True + + def test_false_when_deadline_passes(self): + with patch(f"{_MOD}.os.waitpid", return_value=(0, 0)): # never exits + assert _wait_for_exit(111, time.monotonic() - 1) is False # already past + + +class TestJoinChildren: + def test_clean_exit_no_sigkill(self): + f = _Fleet(1) + f.record_fork(0, 111) + with ( + patch(f"{_MOD}._wait_for_exit", return_value=True), + patch(f"{_MOD}.os.kill") as kill, + ): + _join_children(f, grace_seconds=5) + kill.assert_not_called() + + def test_straggler_is_sigkilled_and_reaped(self): + f = _Fleet(1) + f.record_fork(0, 111) + with ( + patch(f"{_MOD}._wait_for_exit", return_value=False), # never drained + patch(f"{_MOD}.os.kill") as kill, + patch(f"{_MOD}.os.waitpid") as waitpid, + ): + _join_children(f, grace_seconds=5) + import signal as _signal + + kill.assert_called_once_with(111, _signal.SIGKILL) + waitpid.assert_called_once_with(111, 0) + + +class TestSupervisorHealth: + def test_no_port_returns_none(self, monkeypatch): + monkeypatch.delenv("WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT", raising=False) + assert sup._maybe_start_supervisor_health(_Fleet(1)) is None + + def test_bind_error_is_swallowed(self, monkeypatch): + monkeypatch.setenv("WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT", "8090") + server = MagicMock() + server.start.side_effect = OSError(errno.EADDRINUSE, "in use") + # LivenessServer is imported locally inside the function → patch its source. + with patch( + "queue_backend.pg_queue.liveness.LivenessServer", return_value=server + ): + # Must not propagate — the consumer keeps draining without a probe. + assert sup._maybe_start_supervisor_health(_Fleet(1)) is None From 46c53d187ac09a1fe0d460da847957d7113cd49a Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:01:03 +0530 Subject: [PATCH 31/65] =?UTF-8?q?UN-3608=20[FEAT]=20PG=20Queue=209i=20?= =?UTF-8?q?=E2=80=94=20executor=20async/callback=20path=20on=20PG=20(self-?= =?UTF-8?q?chained=20continuations)=20(#2097)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3608 [FEAT] PG Queue 9i — executor async/callback path on PG (self-chained continuations) Migrate the executor RPC's async/callback path off Celery onto the PG queue, completing the executor-transport migration (the blocking path landed in 9h-c). - dispatch_async / dispatch_with_callback now route PG-vs-Celery per call via the single pg_queue_enabled flag (was always Celery). Backend + workers. - §5 fire-and-forget self-chaining: on_success/on_error Celery Signatures are translated to serialisable ContinuationSpecs carried in the payload; after the executor runs execute_extraction the consumer self-chains the matching continuation onto the callback queue (result prepended on success, dispatch task_id on error), acking regardless to avoid an LLM double-spend. - New gated worker-pg-ide-callback consumer drains the ide_callback queue (Prompt Studio run/index/extract, lookups). Compose profile pg-queue. - ContinuationSpec + on_success/on_error/task_id added to the shared TaskPayload wire contract (unstract.core). Zero-regression: every new path is gated and fails closed to Celery; gate OFF is byte-identical to the prior Celery behaviour. Call sites unchanged — they keep passing Celery Signatures; the dispatcher translates only on the PG branch. Tests: payload set/unset, signature->spec translation, consumer self-chain success/error + enqueue-failure guard, routing gate (backend + workers). Co-Authored-By: Claude Opus 4.8 * UN-3608 [REFACTOR] lift transport-agnostic dispatch helpers to unstract.core Cuts the new-code duplication SonarCloud flagged on the backend/workers executor_rpc.py mirror by moving the two genuinely transport-agnostic pieces of the async/callback path into unstract.core (alongside ContinuationSpec): - DispatchHandle (the .id-only AsyncResult duck-type) - signature_to_continuation (Celery Signature -> ContinuationSpec) Both mirrors now import them instead of redefining; the duplicated TestSignatureToSpec is removed from both test suites in favour of one shared test. The dispatch_async/dispatch_with_callback method bodies stay mirrored (they genuinely differ by transport — Django ORM vs psycopg2); retiring that residue is the separate shared-package follow-up (UN-3607). No behaviour change: pure code movement, gate and contract unchanged. Co-Authored-By: Claude Opus 4.8 * UN-3608 [FIX] address review — on_error on drop branches, error text, json-safety, guards Toolkit review on #2097: - Critical: chain on_error on the early-drop branches too (malformed / poison / unknown task), via a shared _fail_dispatch — a dispatch_with_callback failure always reaches its on_error callback, not only when the task body raised. - Carry the real error text to on_error via callback_kwargs['error'] (PG has no Celery AsyncResult for the callback to recover it from); error callbacks prefer it. - JSON-coerce the self-chained prepend (UUID/datetime in an executor result no longer makes client.send's plain json.dumps raise and silently drop the callback). - Enforce the reply_key XOR on_success/on_error invariant in to_payload + enqueue_task. - _json_safe the continuation specs in the backend producer. - signature_to_continuation: guard task name + reject positional-arg callbacks. - Restore ContinuationSpec/TaskPayload types at the consumer boundary; drop the over-broad except in _continuation_org; document the returned-failure->on_success parity; reword the ContinuationSpec doc and the compose "Dark" comment. Tests for every fix. Backend 35, workers 76 green. Co-Authored-By: Claude Opus 4.8 * UN-3608 [FIX] review round 2 — on_success-only redelivery, success-enqueue fallback, ide-callback vt - Correctness: broaden the failure guard to `reply_key or on_success or on_error` so an on_success-only callback dispatch that raises ACKs instead of falling through to vt-redelivery (which would re-run the executor / re-spend LLM tokens). - Silent failure: _chain_continuation now returns whether it enqueued; on a success-path enqueue failure, fall back to on_error so the HTTP-202 caller gets a terminal event instead of hanging. Failure log carries run_id/task_id/org. - ide_callback _get_task_error: `if explicit is not None` (don't discard an explicit "" error) — greptile. - worker-pg-ide-callback: set VT_SECONDS=120 / HEALTH_STALE=180 (overridable) so a slow internal-API write can't be re-claimed mid-run and double-emit — greptile. Tests: on_success-only-raise acks (no redelivery); success-enqueue-failure falls back to on_error. Workers self-chain + ide_callback green. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/pg_queue/executor_rpc.py | 109 ++++++++++- backend/pg_queue/producer.py | 29 ++- backend/pg_queue/tests/test_executor_rpc.py | 81 +++++++- backend/pg_queue/tests/test_producer.py | 30 +++ docker/docker-compose.yaml | 45 +++++ .../core/src/unstract/core/data_models.py | 33 ++++ .../src/unstract/core/execution_dispatch.py | 75 ++++++++ workers/ide_callback/tasks.py | 30 ++- workers/queue_backend/pg_queue/consumer.py | 180 ++++++++++++++++-- .../queue_backend/pg_queue/executor_rpc.py | 126 ++++++++++-- .../queue_backend/pg_queue/task_payload.py | 29 ++- workers/tests/test_executor_rpc.py | 154 ++++++++++++++- workers/tests/test_pg_queue_consumer.py | 165 ++++++++++++++++ 13 files changed, 1036 insertions(+), 50 deletions(-) create mode 100644 unstract/core/src/unstract/core/execution_dispatch.py diff --git a/backend/pg_queue/executor_rpc.py b/backend/pg_queue/executor_rpc.py index 35615671b6..72c0895a35 100644 --- a/backend/pg_queue/executor_rpc.py +++ b/backend/pg_queue/executor_rpc.py @@ -42,6 +42,7 @@ from pg_queue.models import PgTaskResult from pg_queue.producer import enqueue_task from unstract.core.data_models import PgTaskStatus +from unstract.core.execution_dispatch import DispatchHandle, signature_to_continuation from unstract.flags.feature_flag import check_feature_flag_status from unstract.sdk1.execution.dispatcher import ExecutionDispatcher from unstract.sdk1.execution.result import ExecutionResult @@ -186,6 +187,80 @@ def dispatch( ) return ExecutionResult.failure(error=row.error or "executor task failed") + def dispatch_async( + self, context: ExecutionContext, headers: dict[str, Any] | None = None + ) -> str: + """Fire-and-forget enqueue of ``execute_extraction``; returns the task id. + + The PG analogue of the SDK ``dispatch_async``: no ``reply_key``, no + callback, no blocking. There is no PG ``AsyncResult`` backend, so a caller + that needs the outcome uses :meth:`dispatch_with_callback` (a self-chained + continuation), not polling on this id. ``headers`` is accepted and ignored + (PG carries routing in the payload). Enqueue failures propagate — parity + with the SDK, which lets a broker error out of ``dispatch_async``. + """ + task_id = str(uuid.uuid4()) + queue = f"{_QUEUE_PREFIX}{context.executor_name}" + org = getattr(context, "organization_id", "") or "" + enqueue_task( + task_name=_EXECUTE_TASK, + queue=queue, + args=[context.to_dict()], + org_id=str(org), + task_id=task_id, + ) + logger.info( + "PG executor dispatch_async: enqueued task_id=%s queue=%s run_id=%s", + task_id, + queue, + context.run_id, + ) + return task_id + + def dispatch_with_callback( + self, + context: ExecutionContext, + on_success: Any | None = None, + on_error: Any | None = None, + task_id: str | None = None, + headers: dict[str, Any] | None = None, + ) -> DispatchHandle: + """Fire-and-forget enqueue with self-chained callbacks (§5 model). + + The PG analogue of the SDK ``dispatch_with_callback``: instead of Celery + ``link`` / ``link_error`` (which the broker fires), the on-success / + on-error Celery ``Signature``s are translated to serialisable + :class:`ContinuationSpec`s and carried in the payload. After the executor + consumer runs ``execute_extraction`` it self-chains the matching + continuation onto the callback queue. Returns a :class:`DispatchHandle` + exposing ``.id`` (== ``task_id``) so call sites read the task id exactly + as on the Celery path. ``headers`` is accepted and ignored. + """ + task_id = task_id or str(uuid.uuid4()) + queue = f"{_QUEUE_PREFIX}{context.executor_name}" + org = getattr(context, "organization_id", "") or "" + success_spec = signature_to_continuation(on_success) + error_spec = signature_to_continuation(on_error) + enqueue_task( + task_name=_EXECUTE_TASK, + queue=queue, + args=[context.to_dict()], + org_id=str(org), + on_success=success_spec, + on_error=error_spec, + task_id=task_id, + ) + logger.info( + "PG executor dispatch_with_callback: enqueued task_id=%s queue=%s " + "run_id=%s on_success=%s on_error=%s", + task_id, + queue, + context.run_id, + success_spec["task_name"] if success_spec else None, + error_spec["task_name"] if error_spec else None, + ) + return DispatchHandle(task_id) + @staticmethod def _wait_for_result(reply_key: str, timeout: float) -> PgTaskResult | None: """Poll ``pg_task_result`` until the row appears or *timeout* elapses. @@ -216,10 +291,10 @@ def _wait_for_result(reply_key: str, timeout: float) -> PgTaskResult | None: class RoutingExecutionDispatcher: """Gate-routed executor dispatcher returned by ``_get_dispatcher()``. - ``dispatch()`` chooses PG vs Celery per call (instant rollout/rollback); - ``dispatch_async`` / ``dispatch_with_callback`` always delegate to Celery — - the async/callback path stays on Celery until a later continuation slice. - Duck-typed against the SDK ``ExecutionDispatcher`` so call sites are unchanged. + Every mode chooses PG vs Celery per call (instant rollout/rollback): + ``dispatch()`` (request-reply), ``dispatch_async`` (fire-and-forget) and + ``dispatch_with_callback`` (self-chained callbacks). Duck-typed against the SDK + ``ExecutionDispatcher`` so call sites are unchanged. """ def __init__(self, celery_app: object | None = None) -> None: @@ -246,10 +321,32 @@ def dispatch( def dispatch_async( self, context: ExecutionContext, headers: dict[str, Any] | None = None ) -> str: + if resolve_executor_transport(context): + return self._pg.dispatch_async(context) return self._celery.dispatch_async(context, headers=headers) - def dispatch_with_callback(self, context: ExecutionContext, **kwargs: Any) -> Any: - return self._celery.dispatch_with_callback(context, **kwargs) + def dispatch_with_callback( + self, + context: ExecutionContext, + on_success: Any | None = None, + on_error: Any | None = None, + task_id: str | None = None, + headers: dict[str, Any] | None = None, + ) -> Any: + if resolve_executor_transport(context): + return self._pg.dispatch_with_callback( + context, + on_success=on_success, + on_error=on_error, + task_id=task_id, + ) + return self._celery.dispatch_with_callback( + context, + on_success=on_success, + on_error=on_error, + task_id=task_id, + headers=headers, + ) def get_executor_dispatcher( diff --git a/backend/pg_queue/producer.py b/backend/pg_queue/producer.py index f258b8a89a..1056d44476 100644 --- a/backend/pg_queue/producer.py +++ b/backend/pg_queue/producer.py @@ -25,6 +25,7 @@ FAIRNESS_DEFAULT_PRIORITY, FAIRNESS_MAX_PRIORITY, FAIRNESS_MIN_PRIORITY, + ContinuationSpec, FairnessPayload, TaskPayload, ) @@ -63,6 +64,9 @@ def enqueue_task( priority: int = DEFAULT_PRIORITY, fairness: FairnessPayload | None = None, reply_key: str | None = None, + on_success: ContinuationSpec | None = None, + on_error: ContinuationSpec | None = None, + task_id: str | None = None, ) -> int: """Enqueue a task onto the PG queue; returns the new ``msg_id``. @@ -74,7 +78,19 @@ def enqueue_task( ``reply_key`` marks a **request-reply** dispatch (the executor RPC on PG): the executor consumer writes the task's result/error to ``pg_task_result`` under it for the blocking caller to poll. Omitted = fire-and-forget. + + ``on_success`` / ``on_error`` mark an **async/callback** dispatch + (``dispatch_with_callback``): the executor consumer self-chains the matching + continuation after the task runs. ``task_id`` is the dispatch id prepended to + ``on_error`` as the failed id (Celery ``link_error`` parity). Mutually + exclusive with ``reply_key`` — passing both is rejected (the consumer checks + ``reply_key`` first and would silently drop the callback). """ + if reply_key is not None and (on_success is not None or on_error is not None): + raise ValueError( + "reply_key (request-reply) and on_success/on_error (callback) are " + "mutually exclusive" + ) if not FAIRNESS_MIN_PRIORITY <= priority <= FAIRNESS_MAX_PRIORITY: raise ValueError( f"priority out of range " @@ -88,10 +104,19 @@ def enqueue_task( "queue": pg_queue, "fairness": fairness, } - # Only set for request-reply dispatches — keeps fire-and-forget rows - # byte-identical to before this field existed. + # Each optional key is set only when present — keeps fire-and-forget rows + # byte-identical to before these fields existed. if reply_key is not None: message["reply_key"] = reply_key + # Continuation specs carry a nested callback ``kwargs`` dict that may hold a + # UUID/datetime — coerce like ``args``/``kwargs`` above, else the JSONField + # insert raises at dispatch time (caller-visible). + if on_success is not None: + message["on_success"] = _json_safe(on_success) + if on_error is not None: + message["on_error"] = _json_safe(on_error) + if task_id is not None: + message["task_id"] = task_id # Mirror the worker _enqueue_pg path: log the failure with breadcrumbs before # it propagates, so a DB/constraint/serialization error isn't mislabeled by # the caller's broad handler. diff --git a/backend/pg_queue/tests/test_executor_rpc.py b/backend/pg_queue/tests/test_executor_rpc.py index b91f923377..7f5a2ed84f 100644 --- a/backend/pg_queue/tests/test_executor_rpc.py +++ b/backend/pg_queue/tests/test_executor_rpc.py @@ -221,11 +221,82 @@ def test_gate_on_dispatch_uses_pg(self): pg.dispatch.assert_called_once() celery.dispatch.assert_not_called() - def test_async_and_callback_always_celery(self): - """The callback/async path stays on Celery regardless of the gate (a later slice).""" + def test_async_and_callback_stay_celery_when_gate_off(self): + """Zero-regression: gate off → async/callback delegate to Celery unchanged.""" dispatcher, celery, pg = self._build() - dispatcher.dispatch_async(_ctx()) - dispatcher.dispatch_with_callback(_ctx(), on_success=None) + with patch(f"{_MOD}.resolve_executor_transport", return_value=False): + dispatcher.dispatch_async(_ctx(), headers={"h": 1}) + dispatcher.dispatch_with_callback(_ctx(), on_success="s", on_error="e") celery.dispatch_async.assert_called_once() celery.dispatch_with_callback.assert_called_once() - pg.dispatch.assert_not_called() + pg.dispatch_async.assert_not_called() + pg.dispatch_with_callback.assert_not_called() + + def test_async_and_callback_route_to_pg_when_gated(self): + """Gate on (③c) → async/callback take the PG self-chained path.""" + dispatcher, celery, pg = self._build() + with patch(f"{_MOD}.resolve_executor_transport", return_value=True): + dispatcher.dispatch_async(_ctx()) + dispatcher.dispatch_with_callback( + _ctx(), on_success="s", on_error="e", task_id="t" + ) + pg.dispatch_async.assert_called_once() + pg.dispatch_with_callback.assert_called_once() + assert "headers" not in pg.dispatch_with_callback.call_args.kwargs + celery.dispatch_async.assert_not_called() + celery.dispatch_with_callback.assert_not_called() + + +class TestPgAsyncCallbackWiring: + """PG fire-and-forget + self-chained-callback enqueue shapes (``enqueue_task`` + mocked). Pins that the async path carries NO reply_key and the callback path + carries the translated continuations + the tracking task_id. + """ + + @staticmethod + def _ctx(): + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "r" + c.organization_id = "org9" + c.to_dict.return_value = {"run_id": "r", "organization_id": "org9"} + return c + + def test_dispatch_async_is_fire_and_forget(self): + with patch(f"{_MOD}.enqueue_task") as enq: + task_id = PgExecutionDispatcher().dispatch_async(self._ctx()) + kwargs = enq.call_args.kwargs + assert kwargs["task_name"] == "execute_extraction" + assert kwargs["queue"] == "celery_executor_legacy" + assert kwargs["org_id"] == "org9" + assert kwargs["task_id"] == task_id + assert "reply_key" not in kwargs + assert "on_success" not in kwargs + + def test_dispatch_with_callback_carries_continuations(self): + on_s = MagicMock( + task="ide_prompt_complete", + args=(), + kwargs={"callback_kwargs": {"room": "r1"}}, + options={"queue": "ide_callback"}, + ) + on_e = MagicMock( + task="ide_prompt_error", + args=(), + kwargs={"callback_kwargs": {"room": "r1"}}, + options={"queue": "ide_callback"}, + ) + with patch(f"{_MOD}.enqueue_task") as enq: + handle = PgExecutionDispatcher().dispatch_with_callback( + self._ctx(), on_success=on_s, on_error=on_e, task_id="tid-7" + ) + assert handle.id == "tid-7" # call sites read .id off the handle + kwargs = enq.call_args.kwargs + assert kwargs["on_success"] == { + "task_name": "ide_prompt_complete", + "kwargs": {"callback_kwargs": {"room": "r1"}}, + "queue": "ide_callback", + } + assert kwargs["on_error"]["task_name"] == "ide_prompt_error" + assert kwargs["task_id"] == "tid-7" + assert "reply_key" not in kwargs diff --git a/backend/pg_queue/tests/test_producer.py b/backend/pg_queue/tests/test_producer.py index 1d62f3b11e..c09466f2a6 100644 --- a/backend/pg_queue/tests/test_producer.py +++ b/backend/pg_queue/tests/test_producer.py @@ -111,3 +111,33 @@ def test_enqueue_failure_logs_and_propagates(self): model.objects.create.side_effect = RuntimeError("db down") with pytest.raises(RuntimeError): producer.enqueue_task(task_name="t", queue="celery") + + def test_reply_key_and_callback_mutually_exclusive(self): + spec = {"task_name": "cb", "kwargs": {}, "queue": "ide_callback"} + with pytest.raises(ValueError, match="mutually exclusive"): + producer.enqueue_task( + task_name="execute_extraction", + queue="celery_executor_legacy", + reply_key="rk", + on_success=spec, + ) + + def test_continuation_specs_are_json_coerced(self): + # A callback's kwargs can carry a UUID/datetime → must be coerced like + # args/kwargs, else the JSONField insert raises at dispatch (caller-visible). + uid = uuid.UUID("ebed2834-c9fb-4b6c-8df3-9dd841f616bb") + spec = { + "task_name": "ide_prompt_complete", + "kwargs": {"callback_kwargs": {"doc_id": uid}}, + "queue": "ide_callback", + } + with patch(_MODEL) as model: + model.objects.create.return_value = MagicMock(msg_id=1) + producer.enqueue_task( + task_name="execute_extraction", + queue="celery_executor_legacy", + on_success=spec, + task_id="t1", + ) + msg = model.objects.create.call_args.kwargs["message"] + assert msg["on_success"]["kwargs"]["callback_kwargs"]["doc_id"] == str(uid) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index a8f64a00e9..612e35ea8d 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -770,6 +770,51 @@ services: profiles: - pg-queue + # IDE callback consumer (③c) — drains the ``ide_callback`` queue the executor + # self-chains onto for async/callback dispatch (Prompt Studio run/index/extract, + # lookups). Registers the ide_callback worker's tasks (ide_prompt_complete, + # ide_index_complete, extraction_complete, …). Idle (receives no work) until + # dispatch_with_callback routes to PG (i.e. the gating flag is on); the Celery + # ``worker-ide-callback`` still serves the Celery path. No broker dependency: + # these callbacks only do API writes + ws emits, never an onward Celery dispatch. + # Lightweight, so a modest concurrency suffices; scale via replicas (SKIP LOCKED). + worker-pg-ide-callback: + image: unstract/worker-unified:${VERSION} + container_name: unstract-worker-pg-ide-callback + restart: unless-stopped + command: ["pg-queue-consumer"] + ports: + - "8100:8090" + env_file: + - ../workers/.env + - ./essentials.env + depends_on: + - db + - redis + - platform-service + environment: + - ENVIRONMENT=development + - 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 + - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + - WORKER_PG_QUEUE_CONSUMER_CONCURRENCY=${PG_IDE_CALLBACK_CONCURRENCY:-2} + # Callbacks are sub-second (ws emit + small API writes), so nowhere near the + # executor's 1h limit — but set the visibility timeout above a slow internal + # API write (backend under load) so a sibling replica can't re-claim mid-run + # and double-emit. Health-stale sits above it. Overridable per-env. + - WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=${PG_IDE_CALLBACK_VT_SECONDS:-120} + - WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS=${PG_IDE_CALLBACK_HEALTH_STALE_SECONDS:-180} + labels: + - traefik.enable=false + volumes: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + - prompt_studio_data:/app/prompt-studio-data + profiles: + - pg-queue + # Reaper / orchestrator — leader-elected loop. Run exactly ONE instance (it # elects a single leader via pg_orchestrator_lock; extra replicas idle as # standby). Besides barrier-orphan recovery it runs the PG scheduler tick diff --git a/unstract/core/src/unstract/core/data_models.py b/unstract/core/src/unstract/core/data_models.py index cfcbd027e1..08ac61234f 100644 --- a/unstract/core/src/unstract/core/data_models.py +++ b/unstract/core/src/unstract/core/data_models.py @@ -298,6 +298,30 @@ class FairnessPayload(TypedDict): pipeline_priority: int +class ContinuationSpec(TypedDict): + """A self-chained continuation — the PG wire-form of a Celery ``Signature``. + + The async/callback executor path (``dispatch_with_callback``) attaches an + ``on_success`` / ``on_error`` callback. Celery carries these as ``link`` / + ``link_error`` signatures the broker fires automatically; PG has no such + broker mechanism, so the executor consumer **self-chains** instead — after it + runs ``execute_extraction`` it enqueues this continuation onto ``queue`` (the + §5 fire-and-forget model). ``task_name`` / ``kwargs`` / ``queue`` are exactly + the three fields read off a ``signature(task_name, kwargs=..., queue=...)`` so + the prompt-studio call sites keep passing Celery ``Signature``s unchanged and + the dispatcher translates them only on the PG branch. + + The consumer passes the chained value the callback expects (the executor + result dict on success, the dispatch ``task_id`` on error) as the callback's + first positional arg, alongside the spec's ``kwargs`` as a distinct mapping — + mirroring how Celery's ``link`` prepends the parent task's return value. + """ + + task_name: str + kwargs: dict[str, Any] + queue: str + + class TaskPayload(TypedDict): """The ``message`` JSONB of a task row in ``pg_queue_message``. @@ -331,6 +355,15 @@ class TaskPayload(TypedDict): queue: str | None fairness: FairnessPayload | None reply_key: NotRequired[str] + # Async/callback dispatch (``dispatch_with_callback``). Mutually exclusive with + # ``reply_key``: request-reply (blocking) sets ``reply_key``; fire-and-forget + # with callbacks sets these continuations. The executor consumer self-chains + # whichever applies after the task runs. ``task_id`` is the dispatch id the + # caller tracks (== Celery's task id); the consumer prepends it as the failed + # id to ``on_error`` (parity with Celery ``link_error``). + on_success: NotRequired[ContinuationSpec] + on_error: NotRequired[ContinuationSpec] + task_id: NotRequired[str] class PgTaskStatus(str, Enum): diff --git a/unstract/core/src/unstract/core/execution_dispatch.py b/unstract/core/src/unstract/core/execution_dispatch.py new file mode 100644 index 0000000000..57ddf02513 --- /dev/null +++ b/unstract/core/src/unstract/core/execution_dispatch.py @@ -0,0 +1,75 @@ +"""Transport-agnostic helpers for the executor-RPC dispatchers. + +The PG executor dispatch lives in two near-identical mirrors — backend (Django +ORM) and workers (psycopg2) ``pg_queue/executor_rpc.py`` — because neither +codebase can import the other and there is no shared home for the transport logic +yet (the SDK is transport-agnostic by contract, and ``unstract.sdk1`` → +``unstract.core`` would be circular). Retiring that mirror wholesale is tracked +as a follow-up (a dedicated shared executor-RPC package). + +These two helpers, however, are the genuinely **transport-agnostic** pieces of +the async/callback path: they have no Django / psycopg / SDK dependency. So they +live here in ``unstract.core`` (alongside :class:`ContinuationSpec`, the wire +type one of them produces) and BOTH mirrors import them — removing that slice of +the duplication today rather than waiting for the full package. +""" + +from __future__ import annotations + +from typing import Any + +from .data_models import ContinuationSpec + + +class DispatchHandle: + """Minimal duck-type of Celery ``AsyncResult`` for the PG callback path. + + ``dispatch_with_callback`` callers read only ``.id`` (to return the task id in + the HTTP 202 response); they must NOT call ``.get()`` — the result arrives via + the self-chained callback (WebSocket), not by polling here. Exposing just + ``.id`` lets a PG dispatch return the same shape the call sites already use. + """ + + __slots__ = ("id",) + + def __init__(self, task_id: str) -> None: + self.id = task_id + + +def signature_to_continuation(sig: Any | None) -> ContinuationSpec | None: + """Translate a Celery ``Signature`` to a serialisable continuation spec. + + Reads only the three attributes PG self-chaining needs — task name, kwargs, + target queue — so the prompt-studio call sites keep passing + ``signature(name, kwargs=..., queue=...)`` unchanged; only the PG branch + translates. ``None`` (no callback for that outcome) passes through. A signature + without a queue is a configuration error: PG routes by the row's queue and must + not silently default it, so we fail fast. + """ + if sig is None: + return None + task = getattr(sig, "task", None) + if not task: + raise ValueError( + "callback signature has no task name; PG self-chaining cannot enqueue " + "it (it would be dropped downstream as a malformed payload)" + ) + queue = (getattr(sig, "options", None) or {}).get("queue") + if not queue: + raise ValueError( + f"callback signature {task!r} has no queue; PG self-chaining routes " + "by the row's queue and cannot default it" + ) + # ContinuationSpec carries no positional args (the consumer prepends the one + # chained value). A signature with its own positional args would run with a + # different arg list on PG than on Celery — fail fast rather than drop them. + if getattr(sig, "args", None): + raise ValueError( + f"callback signature {task!r} has positional args; PG self-chaining " + "supports kwargs-only callbacks" + ) + return ContinuationSpec( + task_name=task, + kwargs=dict(getattr(sig, "kwargs", None) or {}), + queue=queue, + ) diff --git a/workers/ide_callback/tasks.py b/workers/ide_callback/tasks.py index 0902f4609b..e35b76f933 100644 --- a/workers/ide_callback/tasks.py +++ b/workers/ide_callback/tasks.py @@ -94,8 +94,20 @@ def _emit_event( ) -def _get_task_error(failed_task_id: str, default: str) -> str: - """Retrieve the error message from a failed Celery task's result backend.""" +def _get_task_error( + failed_task_id: str, default: str, explicit: str | None = None +) -> str: + """Resolve the error text for a failed-task callback. + + Prefers an ``explicit`` error when the caller already has it: the PG + self-chained path carries the real error via ``callback_kwargs['error']`` + because the executor ran eagerly (``task.apply``) and never wrote a Celery + result backend under ``failed_task_id`` — so the ``AsyncResult`` lookup below + is empty there. The Celery ``link_error`` path passes no explicit error and + falls back to the result backend, then the ``default``. + """ + if explicit is not None: + return explicit try: from celery.result import AsyncResult @@ -321,7 +333,9 @@ def ide_index_error( api = _get_api_client() try: - error_msg = _get_task_error(failed_task_id, default="Indexing failed") + error_msg = _get_task_error( + failed_task_id, default="Indexing failed", explicit=cb.get("error") + ) # Clean up the indexing-in-progress flag if doc_id_key: @@ -499,7 +513,9 @@ def ide_prompt_error( api = _get_api_client() try: - error_msg = _get_task_error(failed_task_id, default="Prompt execution failed") + error_msg = _get_task_error( + failed_task_id, default="Prompt execution failed", explicit=cb.get("error") + ) _emit_event( api, @@ -642,7 +658,11 @@ def extraction_error( # Context-manage clients to avoid per-task session leaks. with _get_extraction_client() as api, _get_api_client() as ps_api: try: - error_msg = _get_task_error(failed_task_id, default="Text extraction failed") + error_msg = _get_task_error( + failed_task_id, + default="Text extraction failed", + explicit=cb.get("error"), + ) api.mark_extraction_error( source=source, diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index 12081fd647..5b75fe74ce 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -20,6 +20,7 @@ from __future__ import annotations +import json import logging import os import signal @@ -29,10 +30,13 @@ from celery import current_app +from unstract.core.data_models import ContinuationSpec, TaskPayload + from ..fairness import FAIRNESS_HEADER_NAME from .client import PgQueueClient from .liveness import LivenessServer as _BaseLivenessServer from .result_backend import PgResultBackend +from .task_payload import to_payload if TYPE_CHECKING: from celery import Celery @@ -66,6 +70,19 @@ _DEFAULT_HEALTH_STALE_SECONDS = 60.0 +def _json_safe(value: object) -> object: + """Round-trip through JSON with ``default=str`` so non-JSON-native values + (UUID / datetime) survive a self-chained enqueue. + + ``PgQueueClient.send`` serialises with a plain ``json.dumps`` (no + ``default=``), so a self-chained continuation whose prepended argument is an + executor result dict containing a UUID/datetime would raise ``TypeError`` — + swallowed by ``_chain_continuation`` and the callback (plus its user-facing + event) lost. Coercing here mirrors the backend producer's ``_json_safe``. + """ + return json.loads(json.dumps(value, default=str)) + + class PgQueueConsumer: """Polls one PG queue, runs each claimed task in-process, acks on success.""" @@ -164,6 +181,13 @@ def _handle(self, message: QueueMessage) -> None: # its full timeout). Present → store the outcome + ack after one attempt; # absent → fire-and-forget (the existing leaf/pipeline path). reply_key = payload.get("reply_key") + # Async/callback (dispatch_with_callback) self-chaining (③c): a callback + # to enqueue after the task runs (on_success after success, on_error after + # failure) — the PG analogue of Celery firing a link/link_error. Mutually + # exclusive with reply_key. ``task_id`` is the dispatch id prepended to the + # on_error callback as the failed id (Celery link_error parity). + on_success = payload.get("on_success") + on_error = payload.get("on_error") # Malformed / foreign payload: no task name → can't run; drop with a # log that points at the payload, not at task registration. @@ -174,7 +198,7 @@ def _handle(self, message: QueueMessage) -> None: message.msg_id, payload, ) - self._fail_reply(reply_key, "malformed message: missing task_name") + self._fail_dispatch(payload, error="malformed message: missing task_name") self._client.delete(message.msg_id) return @@ -191,8 +215,9 @@ def _handle(self, message: QueueMessage) -> None: message.read_ct, payload, ) - self._fail_reply( - reply_key, f"task {task_name} exceeded max_attempts={self.max_attempts}" + self._fail_dispatch( + payload, + error=f"task {task_name} exceeded max_attempts={self.max_attempts}", ) self._client.delete(message.msg_id) return @@ -205,7 +230,7 @@ def _handle(self, message: QueueMessage) -> None: task_name, message.msg_id, ) - self._fail_reply(reply_key, f"unknown task {task_name}") + self._fail_dispatch(payload, error=f"unknown task {task_name}") self._client.delete(message.msg_id) return @@ -221,22 +246,26 @@ def _handle(self, message: QueueMessage) -> None: throw=True, ) except Exception as exc: - if reply_key: - # Record the failure so the waiting caller gets a definitive - # result instead of blocking to its timeout, then ACK. We do NOT + if reply_key or on_success or on_error: + # Request-reply / async-callback dispatch: surface the failure on + # its return channel (store the error reply, or self-chain on_error + # carrying the real error text), then ACK regardless. We do NOT # vt-redeliver: a redelivery would race a result the caller may # already have consumed, and re-running the executor is costly # (LLM spend). The executor task's own autoretry covers transient - # errors within this attempt; the caller re-dispatches with a - # fresh reply_key to retry the whole RPC. - self._fail_reply(reply_key, f"{type(exc).__name__}: {exc}") + # errors within this attempt; the caller re-dispatches with a fresh + # handle to retry the whole RPC. ``on_success`` is in the guard too: + # an on_success-only callback dispatch (on_error omitted) that raises + # must still ACK — falling through to vt-redelivery would re-run the + # executor (the LLM double-spend this path exists to avoid). + # _fail_dispatch is best-effort, so the ack never wedges. + self._fail_dispatch(payload, error=f"{type(exc).__name__}: {exc}") self._client.delete(message.msg_id) # ack logger.exception( - "PG-queue consumer: request-reply task %r (msg_id=%s) failed " - "— stored error + acked (reply_key=%s)", + "PG-queue consumer: dispatch %r (msg_id=%s) failed — surfaced " + "via reply/on_error + acked", task_name, message.msg_id, - reply_key, ) return # Fire-and-forget: leave the row — its vt expires and it is @@ -267,6 +296,27 @@ def _handle(self, message: QueueMessage) -> None: message.msg_id, reply_key, ) + elif on_success: + # Async/callback: self-chain the success continuation onto the callback + # queue before the ack — the §5 hand-off (PG analogue of Celery's link). + # + # "success" == the task did not RAISE (parity with Celery `link`, which + # also fires on any non-exception return). A task that *returns* a failed + # ExecutionResult (success=False, no raise) deliberately follows this + # path — the on_success callback receives the failed result and renders + # it — exactly as on Celery. This is NOT the missing-on_error drop bug + # the early-drop branches handle. + # + # If the success hand-off can't be enqueued (transport/DB error, bad + # queue), fall back to on_error so the HTTP-202 caller still gets a + # terminal event instead of hanging after the LLM spend already + # happened. Both are best-effort + still ack (no executor re-run). + if not self._chain_continuation( + on_success, prepend=eager.result, payload=payload + ): + self._fail_dispatch( + payload, error="result delivery failed; see worker logs" + ) if not self._client.delete(message.msg_id): # ack logger.warning( @@ -289,6 +339,110 @@ def _store_reply( self._result_backend = PgResultBackend() self._result_backend.store_result(reply_key, result=result, error=error) + def _chain_continuation( + self, + spec: ContinuationSpec, + *, + prepend: object, + payload: TaskPayload, + error: str | None = None, + ) -> bool: + """Enqueue a self-chained callback continuation (best-effort, never raises). + + The PG analogue of Celery firing a ``link`` / ``link_error``: after the + executor task runs, enqueue ``spec`` (``task_name`` + ``kwargs`` + + ``queue``) onto its queue with ``prepend`` as the first positional arg — + the executor result dict on success, the dispatch ``task_id`` on error — + exactly the first parameter the callback signature expects (mirroring + Celery prepending the parent task's return value). The prepended value is + JSON-coerced so a UUID/datetime in an executor result can't make the + enqueue's ``json.dumps`` raise and silently drop the callback. + + ``error`` (failure path only): the real error text. On PG there is no + Celery ``AsyncResult`` for the on_error callback (e.g. ``ide_prompt_error``) + to recover the message from — the executor ran eagerly — so we hand it + through ``callback_kwargs['error']``, which those callbacks prefer over the + empty ``AsyncResult`` lookup. Absent on the success path. + + Returns ``True`` if the continuation was enqueued, ``False`` if it failed. + Never raises: a failure here must not wedge the executor message's ack + (which is taken regardless, to avoid an expensive re-run). The success path + uses the return value to fall back to on_error so the caller still gets a + terminal event; the failure path can't recover further (the callback — and + its user-facing WebSocket event — is lost, logged loud with the run/task/org + so the stranded session is correlatable). + """ + try: + queue = spec["queue"] + kwargs = dict(spec.get("kwargs") or {}) + if error is not None: + cb = dict(kwargs.get("callback_kwargs") or {}) + cb.setdefault("error", error) + kwargs["callback_kwargs"] = cb + self._client.send( + queue, + to_payload( + spec["task_name"], + args=[_json_safe(prepend)], + kwargs=kwargs, + queue=queue, + ), + org_id=self._continuation_org(payload), + ) + return True + except Exception: + ctx = payload.get("args") or [{}] + ctx0 = ctx[0] if ctx and isinstance(ctx[0], dict) else {} + logger.exception( + "PG-queue consumer: FAILED to self-chain continuation %r — the " + "callback (and its user-facing event) is lost " + "(run_id=%s task_id=%s org=%s)", + spec.get("task_name") if isinstance(spec, dict) else spec, + ctx0.get("run_id"), + payload.get("task_id"), + self._continuation_org(payload), + ) + return False + + @staticmethod + def _continuation_org(payload: TaskPayload) -> str: + """Best-effort org id for a chained callback (fairness/debug on its queue). + + The executor request carries it in the context dict (``args[0]``); + callbacks are not fairness-critical, so a missing/odd shape degrades to + ``""`` (no org). The guards below cover every non-dict shape, so no + try/except is needed. + """ + args = payload.get("args") or [] + if args and isinstance(args[0], dict): + return str(args[0].get("organization_id") or "") + return "" + + def _fail_dispatch(self, payload: TaskPayload, *, error: str) -> None: + """Surface a terminal dispatch failure on whichever return channel applies. + + Request-reply (``reply_key``) → store the error for the blocking caller. + Async/callback (``on_error``) → self-chain the on_error continuation, + carrying the real ``error`` text. Best-effort + never raises — the caller + acks regardless. Used by BOTH the run-raised path and the early-drop + branches (malformed / poison / unknown task), so a ``dispatch_with_callback`` + failure ALWAYS reaches its on_error callback, not only when the task body + raised (the realistic poison-executor case is exactly when the user most + needs the error surfaced). + """ + reply_key = payload.get("reply_key") + if reply_key: + self._fail_reply(reply_key, error) + return + on_error = payload.get("on_error") + if on_error: + self._chain_continuation( + on_error, + prepend=payload.get("task_id") or "", + payload=payload, + error=error, + ) + def _fail_reply(self, reply_key: str | None, error: str) -> None: """Best-effort failure reply for a request-reply message that can't run or whose run raised (drop / poison / unknown-task / exception). diff --git a/workers/queue_backend/pg_queue/executor_rpc.py b/workers/queue_backend/pg_queue/executor_rpc.py index a68100c8a1..a43754b84b 100644 --- a/workers/queue_backend/pg_queue/executor_rpc.py +++ b/workers/queue_backend/pg_queue/executor_rpc.py @@ -44,7 +44,8 @@ import uuid from typing import TYPE_CHECKING, Any -from unstract.core.data_models import PgTaskStatus +from unstract.core.data_models import ContinuationSpec, PgTaskStatus +from unstract.core.execution_dispatch import DispatchHandle, signature_to_continuation from unstract.flags.feature_flag import check_feature_flag_status from unstract.sdk1.execution.dispatcher import ExecutionDispatcher from unstract.sdk1.execution.result import ExecutionResult @@ -152,7 +153,7 @@ def dispatch( queue = f"{_QUEUE_PREFIX}{context.executor_name}" org = str(getattr(context, "organization_id", "") or "") try: - self._enqueue(queue, context, reply_key, org) + self._enqueue(queue, context, org, reply_key=reply_key) except Exception as exc: logger.exception( "PG executor dispatch: enqueue failed (executor=%s run_id=%s)", @@ -231,22 +232,103 @@ def dispatch( ) return ExecutionResult.failure(error=row.get("error") or "executor task failed") + def dispatch_async( + self, context: ExecutionContext, headers: dict[str, Any] | None = None + ) -> str: + """Fire-and-forget enqueue of ``execute_extraction``; returns the task id. + + The PG analogue of the SDK ``dispatch_async``: no ``reply_key``, no + callback, no blocking. There is no PG ``AsyncResult`` backend, so a caller + that needs the outcome uses :meth:`dispatch_with_callback` (a self-chained + continuation), not polling on this id. ``headers`` is accepted and ignored + for substitutability (PG carries routing in the payload, not Celery + headers). Enqueue failures propagate — parity with the SDK, which lets a + broker error out of ``dispatch_async``. + """ + task_id = str(uuid.uuid4()) + queue = f"{_QUEUE_PREFIX}{context.executor_name}" + org = str(getattr(context, "organization_id", "") or "") + self._enqueue(queue, context, org, task_id=task_id) + logger.info( + "PG executor dispatch_async: enqueued task_id=%s queue=%s run_id=%s", + task_id, + queue, + context.run_id, + ) + return task_id + + def dispatch_with_callback( + self, + context: ExecutionContext, + on_success: Any | None = None, + on_error: Any | None = None, + task_id: str | None = None, + headers: dict[str, Any] | None = None, + ) -> DispatchHandle: + """Fire-and-forget enqueue with self-chained callbacks (§5 model). + + The PG analogue of the SDK ``dispatch_with_callback``: instead of Celery + ``link`` / ``link_error`` (which the broker fires), the on-success / + on-error Celery ``Signature``s are translated to serialisable + :class:`ContinuationSpec`s and carried in the payload. After the executor + consumer runs ``execute_extraction`` it self-chains the matching + continuation onto the callback queue. Returns a :class:`DispatchHandle` + exposing ``.id`` (== ``task_id``) so call sites read the task id exactly + as on the Celery path. ``headers`` is accepted and ignored (see + :meth:`dispatch_async`). + """ + task_id = task_id or str(uuid.uuid4()) + queue = f"{_QUEUE_PREFIX}{context.executor_name}" + org = str(getattr(context, "organization_id", "") or "") + success_spec = signature_to_continuation(on_success) + error_spec = signature_to_continuation(on_error) + self._enqueue( + queue, + context, + org, + on_success=success_spec, + on_error=error_spec, + task_id=task_id, + ) + logger.info( + "PG executor dispatch_with_callback: enqueued task_id=%s queue=%s " + "run_id=%s on_success=%s on_error=%s", + task_id, + queue, + context.run_id, + success_spec["task_name"] if success_spec else None, + error_spec["task_name"] if error_spec else None, + ) + return DispatchHandle(task_id) + @staticmethod def _enqueue( - queue: str, context: ExecutionContext, reply_key: str, org_id: str + queue: str, + context: ExecutionContext, + org_id: str, + *, + reply_key: str | None = None, + on_success: ContinuationSpec | None = None, + on_error: ContinuationSpec | None = None, + task_id: str | None = None, ) -> None: - """Enqueue the ``execute_extraction`` request-reply message. + """Enqueue an ``execute_extraction`` message (request-reply or callback). A short-lived client owns its connection for just the insert (which commits internally) so the message is durably visible to the ``worker-pg-executor`` consumer before we begin polling — and no - connection is pinned for the whole (possibly long) RPC. + connection is pinned for the whole (possibly long) RPC. The optional keys + select the dispatch shape: ``reply_key`` → request-reply; ``on_success`` / + ``on_error`` / ``task_id`` → async/callback (self-chained). """ payload = to_payload( _EXECUTE_TASK, args=[context.to_dict()], queue=queue, reply_key=reply_key, + on_success=on_success, + on_error=on_error, + task_id=task_id, ) with PgQueueClient() as client: client.send(queue, payload, org_id=org_id) @@ -272,10 +354,10 @@ def _wait_for_result(reply_key: str, timeout: float) -> dict[str, Any] | None: class RoutingExecutionDispatcher: """Gate-routed executor dispatcher returned by :func:`get_executor_dispatcher`. - ``dispatch()`` chooses PG vs Celery per call (instant rollout/rollback); - ``dispatch_async`` / ``dispatch_with_callback`` always delegate to Celery — - the async/callback path stays on Celery until a later continuation slice. - Duck-typed against the SDK ``ExecutionDispatcher`` so call sites are unchanged. + Every mode chooses PG vs Celery per call (instant rollout/rollback): + ``dispatch()`` (request-reply), ``dispatch_async`` (fire-and-forget) and + ``dispatch_with_callback`` (self-chained callbacks). Duck-typed against the SDK + ``ExecutionDispatcher`` so call sites are unchanged. """ def __init__(self, celery_app: object | None = None) -> None: @@ -303,10 +385,32 @@ def dispatch( def dispatch_async( self, context: ExecutionContext, headers: dict[str, Any] | None = None ) -> str: + if resolve_executor_transport(context): + return self._pg.dispatch_async(context) return self._celery.dispatch_async(context, headers=headers) - def dispatch_with_callback(self, context: ExecutionContext, **kwargs: Any) -> Any: - return self._celery.dispatch_with_callback(context, **kwargs) + def dispatch_with_callback( + self, + context: ExecutionContext, + on_success: Any | None = None, + on_error: Any | None = None, + task_id: str | None = None, + headers: dict[str, Any] | None = None, + ) -> Any: + if resolve_executor_transport(context): + return self._pg.dispatch_with_callback( + context, + on_success=on_success, + on_error=on_error, + task_id=task_id, + ) + return self._celery.dispatch_with_callback( + context, + on_success=on_success, + on_error=on_error, + task_id=task_id, + headers=headers, + ) def get_executor_dispatcher( diff --git a/workers/queue_backend/pg_queue/task_payload.py b/workers/queue_backend/pg_queue/task_payload.py index da80f6c155..cefcf70e48 100644 --- a/workers/queue_backend/pg_queue/task_payload.py +++ b/workers/queue_backend/pg_queue/task_payload.py @@ -17,7 +17,7 @@ # re-exported here so existing ``from .task_payload import TaskPayload`` imports # keep working. The ``to_payload`` builder stays worker-side (it depends on the # worker-only ``FairnessKey``). -from unstract.core.data_models import TaskPayload +from unstract.core.data_models import ContinuationSpec, TaskPayload if TYPE_CHECKING: from ..fairness import FairnessKey @@ -33,13 +33,30 @@ def to_payload( queue: str | None = None, fairness: FairnessKey | None = None, reply_key: str | None = None, + on_success: ContinuationSpec | None = None, + on_error: ContinuationSpec | None = None, + task_id: str | None = None, ) -> TaskPayload: """Build the JSON-serialisable task payload for the PG queue. ``reply_key`` marks a **request-reply** dispatch (the executor RPC on PG): the executor consumer writes the task's result/error to ``pg_task_result`` under it for the blocking caller to poll. Omitted = fire-and-forget. + + ``on_success`` / ``on_error`` mark an **async/callback** dispatch + (``dispatch_with_callback``): the executor consumer self-chains the matching + continuation after the task runs (success → ``on_success``, failure → + ``on_error``). ``task_id`` is the dispatch id the consumer prepends to + ``on_error`` as the failed id (Celery ``link_error`` parity). These are + mutually exclusive with ``reply_key`` (blocking vs callback dispatch) — passing + both is rejected, since the consumer checks ``reply_key`` first and would + silently drop the callback. """ + if reply_key is not None and (on_success is not None or on_error is not None): + raise ValueError( + "reply_key (request-reply) and on_success/on_error (callback) are " + "mutually exclusive" + ) payload = TaskPayload( task_name=task_name, args=list(args) if args is not None else [], @@ -47,8 +64,14 @@ def to_payload( queue=queue, fairness=fairness.to_dict() if fairness is not None else None, ) - # Only set for request-reply dispatches — keeps fire-and-forget rows - # byte-identical to before this field existed (mirrors the backend producer). + # Each optional key is set only when present — keeps fire-and-forget rows + # byte-identical to before these fields existed (mirrors the backend producer). if reply_key is not None: payload["reply_key"] = reply_key + if on_success is not None: + payload["on_success"] = on_success + if on_error is not None: + payload["on_error"] = on_error + if task_id is not None: + payload["task_id"] = task_id return payload diff --git a/workers/tests/test_executor_rpc.py b/workers/tests/test_executor_rpc.py index ebf93b5889..823804e2a9 100644 --- a/workers/tests/test_executor_rpc.py +++ b/workers/tests/test_executor_rpc.py @@ -11,11 +11,13 @@ from unittest.mock import MagicMock, patch +import pytest from queue_backend.pg_queue.executor_rpc import ( PgExecutionDispatcher, RoutingExecutionDispatcher, resolve_executor_transport, ) +from unstract.core.execution_dispatch import DispatchHandle, signature_to_continuation _MOD = "queue_backend.pg_queue.executor_rpc" @@ -298,11 +300,153 @@ def test_gate_on_passes_timeout_to_pg_and_drops_headers(self): assert "headers" not in pg.dispatch.call_args.kwargs celery.dispatch.assert_not_called() - def test_async_and_callback_always_celery(self): - """The callback/async path stays on Celery regardless of the gate (a later slice).""" + def test_async_and_callback_stay_celery_when_gate_off(self): + """Zero-regression: gate off → async/callback delegate to Celery unchanged.""" dispatcher, celery, pg = self._build() - dispatcher.dispatch_async(_ctx()) - dispatcher.dispatch_with_callback(_ctx(), on_success=None) + with patch(f"{_MOD}.resolve_executor_transport", return_value=False): + dispatcher.dispatch_async(_ctx(), headers={"h": 1}) + dispatcher.dispatch_with_callback(_ctx(), on_success="s", on_error="e") celery.dispatch_async.assert_called_once() celery.dispatch_with_callback.assert_called_once() - pg.dispatch.assert_not_called() + pg.dispatch_async.assert_not_called() + pg.dispatch_with_callback.assert_not_called() + + def test_async_and_callback_route_to_pg_when_gated(self): + """Gate on (③c) → async/callback take the PG self-chained path.""" + dispatcher, celery, pg = self._build() + with patch(f"{_MOD}.resolve_executor_transport", return_value=True): + dispatcher.dispatch_async(_ctx()) + dispatcher.dispatch_with_callback( + _ctx(), on_success="s", on_error="e", task_id="t" + ) + pg.dispatch_async.assert_called_once() + pg.dispatch_with_callback.assert_called_once() + # PG carries callbacks in the payload, not Celery headers → no header leak. + assert "headers" not in pg.dispatch_with_callback.call_args.kwargs + celery.dispatch_async.assert_not_called() + celery.dispatch_with_callback.assert_not_called() + + +class TestPgAsyncCallbackWiring: + """PG fire-and-forget + self-chained-callback enqueue shapes. + + ``to_payload`` runs for real; only ``PgQueueClient`` is mocked. Pins that the + async path carries NO reply_key (it must not block a consumer) and the callback + path carries the translated continuations + the tracking task_id. + """ + + @staticmethod + def _ctx(): + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "r" + c.organization_id = "org9" + c.to_dict.return_value = {"run_id": "r", "organization_id": "org9"} + return c + + @staticmethod + def _client(): + client = MagicMock() + client.__enter__.return_value = client + return client + + def test_dispatch_async_is_fire_and_forget(self): + client = self._client() + with patch(f"{_MOD}.PgQueueClient", return_value=client): + task_id = PgExecutionDispatcher().dispatch_async(self._ctx()) + client.send.assert_called_once() + queue_arg, payload_arg = client.send.call_args.args[:2] + assert queue_arg == "celery_executor_legacy" + assert client.send.call_args.kwargs["org_id"] == "org9" + assert payload_arg["task_name"] == "execute_extraction" + assert payload_arg["task_id"] == task_id + # No reply_key (would make a consumer try to store a reply) and no callback. + assert "reply_key" not in payload_arg + assert "on_success" not in payload_arg + assert "on_error" not in payload_arg + + def test_dispatch_with_callback_carries_continuations(self): + client = self._client() + on_s = MagicMock( + task="ide_prompt_complete", + args=(), + kwargs={"callback_kwargs": {"room": "r1"}}, + options={"queue": "ide_callback"}, + ) + on_e = MagicMock( + task="ide_prompt_error", + args=(), + kwargs={"callback_kwargs": {"room": "r1"}}, + options={"queue": "ide_callback"}, + ) + with patch(f"{_MOD}.PgQueueClient", return_value=client): + handle = PgExecutionDispatcher().dispatch_with_callback( + self._ctx(), on_success=on_s, on_error=on_e, task_id="tid-7" + ) + assert handle.id == "tid-7" # call sites read .id off the handle + payload_arg = client.send.call_args.args[1] + assert payload_arg["on_success"] == { + "task_name": "ide_prompt_complete", + "kwargs": {"callback_kwargs": {"room": "r1"}}, + "queue": "ide_callback", + } + assert payload_arg["on_error"]["task_name"] == "ide_prompt_error" + assert payload_arg["task_id"] == "tid-7" + assert "reply_key" not in payload_arg # callback, not request-reply + + def test_dispatch_with_callback_defaults_task_id(self): + client = self._client() + with patch(f"{_MOD}.PgQueueClient", return_value=client): + handle = PgExecutionDispatcher().dispatch_with_callback(self._ctx()) + # No task_id passed → a uuid is generated and echoed on the handle + payload. + assert handle.id + assert client.send.call_args.args[1]["task_id"] == handle.id + + +class TestSharedDispatchHelpers: + """The transport-agnostic helpers lifted to ``unstract.core`` (shared by the + backend + workers executor-RPC mirrors). Tested once here, not per-mirror. + """ + + def test_signature_none_passes_through(self): + assert signature_to_continuation(None) is None + + def test_signature_translates_task_kwargs_and_queue(self): + sig = MagicMock( + task="ide_prompt_complete", + args=(), # a real kwargs-only Celery Signature has empty .args + kwargs={"callback_kwargs": {"room": "r1"}}, + options={"queue": "ide_callback"}, + ) + assert signature_to_continuation(sig) == { + "task_name": "ide_prompt_complete", + "kwargs": {"callback_kwargs": {"room": "r1"}}, + "queue": "ide_callback", + } + + def test_signature_missing_queue_fails_fast(self): + sig = MagicMock(task="ide_prompt_complete", kwargs={}, options={"queue": ""}) + with pytest.raises(ValueError, match="no queue"): + signature_to_continuation(sig) + + def test_signature_missing_task_fails_fast(self): + sig = MagicMock(task=None, kwargs={}, options={"queue": "ide_callback"}) + with pytest.raises(ValueError, match="no task name"): + signature_to_continuation(sig) + + def test_signature_with_positional_args_fails_fast(self): + sig = MagicMock( + task="ide_prompt_complete", + args=("pos",), + kwargs={}, + options={"queue": "ide_callback"}, + ) + with pytest.raises(ValueError, match="positional args"): + signature_to_continuation(sig) + + def test_dispatch_handle_exposes_only_id(self): + handle = DispatchHandle("tid-1") + assert handle.id == "tid-1" + # __slots__ → no stray attributes (callers must not poke at .get()/.result). + with pytest.raises(AttributeError): + handle.result = 1 # type: ignore[attr-defined] diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index 05ed6e9869..5a731b7b9f 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -34,6 +34,15 @@ def _boom_task(): raise RuntimeError("boom") +@shared_task(name="test_pg_consumer.dt") +def _dt_task(): + # Returns a non-JSON-native value (datetime) — the self-chain must coerce it + # before enqueue, else client.send's plain json.dumps would raise. + from datetime import datetime + + return {"when": datetime(2020, 1, 1)} # noqa: DTZ001 — fixed value for the test + + @pytest.fixture(autouse=True) def _clear_calls(): _calls.clear() @@ -521,5 +530,161 @@ def test_store_failure_on_success_path_still_acks(self, caplog): assert "FAILED to store request-reply result" in caplog.text +class TestSelfChain: + """Async/callback self-chaining (③c): with no reply_key, the consumer enqueues + the on_success / on_error continuation onto its queue after the task runs, then + acks regardless (a vt-redelivery would re-run the executor = LLM double-spend). + Best-effort: a chain-enqueue failure still acks. ``client.send`` is the + self-chain enqueue (the mock client is also read/delete). + """ + + @staticmethod + def _spec(task="cb.done", queue="ide_callback"): + return { + "task_name": task, + "kwargs": {"callback_kwargs": {"room": "r1"}}, + "queue": queue, + } + + def test_success_chains_on_success_with_result_and_acks(self): + client = MagicMock() + payload = {**_ok_payload(3, 4), "on_success": self._spec(), "task_id": "tid"} + client.read.return_value = [_msg(1, payload)] + PgQueueConsumer(["q"], client=client).poll_once() + client.delete.assert_called_once_with(1) # executor msg acked + client.send.assert_called_once() # continuation enqueued + queue_arg, payload_arg = client.send.call_args.args[:2] + assert queue_arg == "ide_callback" + assert payload_arg["task_name"] == "cb.done" + assert payload_arg["args"] == [7] # x + y prepended (Celery link parity) + assert payload_arg["kwargs"] == {"callback_kwargs": {"room": "r1"}} + + def test_failure_chains_on_error_with_task_id_and_acks(self): + client = MagicMock() + payload = { + "task_name": "test_pg_consumer.boom", + "on_error": self._spec("cb.err"), + "task_id": "tid-9", + } + client.read.return_value = [_msg(2, payload)] + PgQueueConsumer(["q"], client=client).poll_once() + client.delete.assert_called_once_with(2) # acked, NOT left for redelivery + client.send.assert_called_once() + payload_arg = client.send.call_args.args[1] + assert payload_arg["task_name"] == "cb.err" + assert payload_arg["args"] == ["tid-9"] # dispatch task_id as the failed id + # The real error text rides callback_kwargs['error'] (no Celery AsyncResult + # to recover it from on PG) so on_error callbacks surface it, not a default. + assert "RuntimeError: boom" in payload_arg["kwargs"]["callback_kwargs"]["error"] + + def test_early_drop_branches_still_chain_on_error(self): + # Critical: a dispatch_with_callback that hits malformed/unknown/poison must + # still fire on_error — else the HTTP-202 caller hangs with no terminal event. + err = self._spec("cb.err") + cases = [ + # (msg_id, payload, read_ct) — malformed / unknown / poison + (10, {"on_error": err, "task_id": "t"}, 1), + (11, {"task_name": "nope.nope", "on_error": err, "task_id": "t"}, 1), + (12, {"task_name": "test_pg_consumer.boom", "on_error": err, "task_id": "t"}, 99), + ] + for msg_id, payload, read_ct in cases: + client = MagicMock() + client.read.return_value = [_msg(msg_id, payload, read_ct=read_ct)] + PgQueueConsumer(["q"], client=client).poll_once() + client.delete.assert_called_once_with(msg_id) # dropped/acked + client.send.assert_called_once() # on_error STILL chained + payload_arg = client.send.call_args.args[1] + assert payload_arg["task_name"] == "cb.err" + assert payload_arg["args"] == ["t"] + assert payload_arg["kwargs"]["callback_kwargs"]["error"] # the drop reason + + def test_non_json_safe_result_is_coerced_before_chaining(self): + client = MagicMock() + payload = { + "task_name": "test_pg_consumer.dt", + "on_success": self._spec(), + "task_id": "tid", + } + client.read.return_value = [_msg(6, payload)] + PgQueueConsumer(["q"], client=client).poll_once() + client.send.assert_called_once() # not swallowed by a json.dumps TypeError + chained = client.send.call_args.args[1]["args"][0] + assert isinstance(chained["when"], str) # datetime → str (default=str) + import json as _json + + _json.dumps(chained) # the coerced payload is plain-json serialisable + + def test_on_success_only_failure_acks_not_redelivered(self): + # An on_success-only callback dispatch (on_error omitted) whose executor + # RAISES must ACK — not fall through to vt-redelivery and re-run the + # executor (LLM double-spend). No on_error → nothing chained, but acked. + client = MagicMock() + payload = { + "task_name": "test_pg_consumer.boom", + "on_success": self._spec(), + "task_id": "tid", + } + client.read.return_value = [_msg(7, payload)] + PgQueueConsumer(["q"], client=client).poll_once() + client.delete.assert_called_once_with(7) # acked, NOT left for redelivery + client.send.assert_not_called() # success callback not fired on a raise + + def test_on_success_enqueue_failure_falls_back_to_on_error(self): + # If the success hand-off can't be enqueued, surface on_error so the caller + # still gets a terminal event instead of hanging after the LLM spend. + client = MagicMock() + client.send.side_effect = [RuntimeError("queue down"), 999] # 1st fails, 2nd ok + payload = { + **_ok_payload(2, 2), + "on_success": self._spec("cb.ok"), + "on_error": self._spec("cb.err"), + "task_id": "tid", + } + client.read.return_value = [_msg(8, payload)] + PgQueueConsumer(["q"], client=client).poll_once() + assert client.send.call_count == 2 # on_success attempt, then on_error fallback + fallback = client.send.call_args_list[1].args[1] + assert fallback["task_name"] == "cb.err" + assert "delivery failed" in fallback["kwargs"]["callback_kwargs"]["error"] + client.delete.assert_called_once_with(8) # acked + + def test_no_continuation_is_plain_fire_and_forget(self): + client = MagicMock() + client.read.return_value = [_msg(3, _ok_payload(1, 1))] + PgQueueConsumer(["q"], client=client).poll_once() + client.send.assert_not_called() # nothing self-chained + client.delete.assert_called_once_with(3) # acked + + def test_chain_failure_on_success_still_acks(self, caplog): + client = MagicMock() + client.send.side_effect = RuntimeError("queue down") + payload = {**_ok_payload(2, 2), "on_success": self._spec()} + client.read.return_value = [_msg(4, payload)] + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): + PgQueueConsumer(["q"], client=client).poll_once() + client.delete.assert_called_once_with(4) # acked despite the chain failure + assert "FAILED to self-chain" in caplog.text + + def test_continuation_org_extracted_from_context(self): + # The chained callback inherits the executor request's org (context dict); + # non-dict / absent args degrade to "" (callbacks aren't fairness-critical). + org_of = PgQueueConsumer._continuation_org + assert org_of({"args": [{"organization_id": "orgZ"}]}) == "orgZ" + assert org_of({"args": [42]}) == "" + assert org_of({}) == "" + + def test_reply_key_and_callback_are_mutually_exclusive(self): + # The consumer checks reply_key first and would silently drop a callback — + # so to_payload rejects the ambiguous combination at the build boundary. + spec = self._spec() + with pytest.raises(ValueError, match="mutually exclusive"): + to_payload("execute_extraction", reply_key="rk", on_success=spec) + with pytest.raises(ValueError, match="mutually exclusive"): + to_payload("execute_extraction", reply_key="rk", on_error=spec) + # Either alone is fine. + assert to_payload("execute_extraction", reply_key="rk")["reply_key"] == "rk" + assert to_payload("execute_extraction", on_success=spec)["on_success"] == spec + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 1c7723b9d6f101f40014555204c1f0f71683d808 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:34:32 +0530 Subject: [PATCH 32/65] =?UTF-8?q?UN-3610=20[FEAT]=20PG=20Queue=20=E2=80=94?= =?UTF-8?q?=20reaper=20retention=20sweep=20(pg=5Ftask=5Fresult=20+=20pg=5F?= =?UTF-8?q?batch=5Fdedup=20orphans)=20(#2101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3610 [FEAT] PG Queue — reaper retention sweep (pg_task_result + pg_batch_dedup orphans) Wire the deferred retention sweep into the PG reaper, before the gate ramps: - sweep_expired_results(conn) — DELETE FROM pg_task_result WHERE expires_at <= now(). Every result row already carries expires_at = now()+3600s (written by the consumer's store_result) and the expires index exists; this is the only reader that keeps the table from growing unbounded with each executor RPC. - sweep_orphan_dedup(conn, retention) — DELETE FROM pg_batch_dedup WHERE created_at <= now() - interval. Backstop for partial-failure executions whose per-batch markers neither clear on teardown nor get reclaimed by barrier recovery. Retention (default 24h) must exceed the longest execution. - Both run in PgReaper.tick() under leadership, after recovery + schedules, cadence-gated by WORKER_PG_REAPER_SWEEP_SECONDS (default 300s) so the ~5s loop doesn't DELETE every cycle; own try/except discards the owned conn on error. - Env knobs surfaced on the compose worker-pg-reaper service. Workers-only: no migration, no writer change, no flag. Idempotent (DELETE WHERE), dark until rows exist. Tests: env parsers, SQL contract, leader-only / standby / cadence-gated / after-recovery / error-discards-conn (27 green). Dev-tested live: the reaper deleted 9 expired pg_task_result + 1 orphaned pg_batch_dedup row. Co-Authored-By: Claude Opus 4.8 * UN-3610 [FIX] address review — independent sweeps, actionable failures, rollback signal Toolkit review on #2101: - [High] Partial-sweep starvation: the two sweeps now run INDEPENDENTLY via _run_sweep — a persistent fault in one no longer skips (and then cadence-gates out) the other. - [High] Sweep failures not actionable: _run_sweep logs each failure at the boundary with the table name + a consecutive-failure streak (distinct from the generic tick-failure log) and does NOT propagate (cleanup must not fail the tick). - [Med] Surface a rollback that itself fails (don't suppress) via _rollback_after_sweep_failure, while still re-raising the original DELETE error. - [Med] Reword the dedup-retention comment as operator-enforced (not code-coupled). - [Low] Soften the result-retention coupling claim (holds by default); note the pg_batch_dedup.created_at seq-scan; de-ambiguate the compose comment. - [type-design] _last_sweep_monotonic is now float | None ("never"), not a 0.0 sentinel. Tests (+8, 35 green): float-vs-int cast distinction, injected-knob constructor guards, sweep_orphan_dedup rollback (parametrized) + rollback-itself-fails log, one-sweep-failing-doesn't-starve-the-other, failing-sweep-still-advances-cadence, log-emission boundary (counts only when rows deleted / silent otherwise). Co-Authored-By: Claude Opus 4.8 * UN-3610 [FIX] greptile — accurate env-parse error + sweep-retry log wording - _positive_duration_from_env: include the cause ("cannot be parsed: ") instead of "is not a number" — an int knob given "1.5" IS a number, just not an integer, so the old message misled. Test asserts the message. - _run_sweep failure log: "will retry after the next sweep interval" (not "next cycle") — the retry is gated by _sweep_interval (~300s), not the ~5s tick. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- docker/docker-compose.yaml | 7 + workers/queue_backend/pg_queue/reaper.py | 208 +++++++++++++++++++- workers/tests/test_pg_reaper.py | 240 +++++++++++++++++++++++ 3 files changed, 454 insertions(+), 1 deletion(-) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 612e35ea8d..0dca79ab4c 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -837,6 +837,13 @@ services: - APPLICATION_NAME=unstract-worker-pg-reaper - WORKER_PG_REAPER_INTERVAL_SECONDS=${WORKER_PG_REAPER_INTERVAL_SECONDS:-5} - WORKER_PG_REAPER_HEALTH_PORT=8086 + # Retention sweep (cadence-gated inside the leader's tick): drop expired + # pg_task_result rows + orphaned pg_batch_dedup markers so neither grows + # unbounded as the gate ramps. The dedup retention below (default 24h) should + # be kept above the longest possible execution, so an in-flight marker is + # never swept. + - WORKER_PG_REAPER_SWEEP_SECONDS=${WORKER_PG_REAPER_SWEEP_SECONDS:-300} + - WORKER_PG_DEDUP_RETENTION_SECONDS=${WORKER_PG_DEDUP_RETENTION_SECONDS:-86400} labels: - traefik.enable=false volumes: diff --git a/workers/queue_backend/pg_queue/reaper.py b/workers/queue_backend/pg_queue/reaper.py index d07e2919e5..4a0c79f5f7 100644 --- a/workers/queue_backend/pg_queue/reaper.py +++ b/workers/queue_backend/pg_queue/reaper.py @@ -51,7 +51,8 @@ import signal import threading import time -from typing import TYPE_CHECKING, NamedTuple, Protocol +from collections.abc import Callable +from typing import TYPE_CHECKING, NamedTuple, Protocol, TypeVar from unstract.core.data_models import ExecutionStatus @@ -69,6 +70,17 @@ # Cadence: how often the leader renews + runs recovery. Enforced shorter than # the lease window in PgReaper.__init__. _DEFAULT_REAPER_INTERVAL_SECONDS = 5.0 +# Retention sweep cadence — far rarer than the tick (cleanup, not recovery), so +# the per-cycle DELETE doesn't run every few seconds. +_DEFAULT_SWEEP_INTERVAL_SECONDS = 300.0 +# Default age before an orphaned ``pg_batch_dedup`` marker is swept. Should be set +# (here / via WORKER_PG_DEDUP_RETENTION_SECONDS) above the longest possible +# execution, else a still-in-flight marker could be swept out from under a running +# fan-out — this is operator-enforced, not coupled in code to the actual bound. 24h +# is comfortably above any single execution. +_DEFAULT_DEDUP_RETENTION_SECONDS = 86400 + +_N = TypeVar("_N", int, float) class LeaderLeaseLike(Protocol): @@ -127,6 +139,115 @@ def reaper_interval_from_env() -> float: return value +def _positive_duration_from_env(name: str, default: _N, cast: Callable[[str], _N]) -> _N: + """Read a positive duration env var (default on unset; raise on invalid/<=0). + + Same loud-on-misconfig posture as :func:`reaper_interval_from_env`, shared by + the sweep-cadence and dedup-retention knobs. + """ + raw = os.getenv(name) + if raw is None: + return default + try: + value = cast(raw) + except ValueError as exc: + # Include the cause: an int knob given "1.5" IS a number, just not an int — + # "is not a number" would mislead. The cause spells out the real reason. + raise ValueError( + f"{name}={raw!r} cannot be parsed: {exc}. Unset it to default to {default}." + ) from exc + if value <= 0: + raise ValueError( + f"{name}={value} must be positive. Unset it to default to {default}." + ) + return value + + +def reaper_sweep_interval_from_env() -> float: + """Retention-sweep cadence from ``WORKER_PG_REAPER_SWEEP_SECONDS`` (default 300s).""" + return _positive_duration_from_env( + "WORKER_PG_REAPER_SWEEP_SECONDS", _DEFAULT_SWEEP_INTERVAL_SECONDS, float + ) + + +def dedup_retention_from_env() -> int: + """Dedup-orphan age from ``WORKER_PG_DEDUP_RETENTION_SECONDS`` (default 24h).""" + return _positive_duration_from_env( + "WORKER_PG_DEDUP_RETENTION_SECONDS", _DEFAULT_DEDUP_RETENTION_SECONDS, int + ) + + +def _rollback_after_sweep_failure(conn: PgConnection, table: str) -> None: + """Roll back after a failed sweep DELETE; surface a rollback that itself fails. + + The caller re-raises the original error regardless — but a rollback that also + raises (broken socket / admin-terminated backend) signals a dead connection, so + log it rather than swallow it silently (which would hide why the next cycle's + reconnect is needed). + """ + try: + conn.rollback() + except Exception: + logger.warning( + "Reaper: rollback after a failed %s sweep also failed " + "(connection likely dead)", + table, + exc_info=True, + ) + + +def sweep_expired_results(conn: PgConnection) -> int: + """Delete expired executor-RPC result rows; return the number deleted. + + ``pg_task_result`` rows carry ``expires_at = now() + retention`` (written by + the consumer's ``store_result``, ``DEFAULT_RETENTION_SECONDS``). Once past it no + caller is still waiting **by default** — that retention matches the caller- + timeout default (``EXECUTOR_RESULT_TIMEOUT``), so the result has outlived any + wait. (An operator who raises the caller timeout ABOVE the store retention could + in principle have a result swept mid-wait; size the retention >= the longest + caller timeout.) The table has no other reader once expired, so this is the only + thing keeping it from growing unbounded with each RPC. Idempotent + (``DELETE … WHERE``) and uses the ``pg_task_result_expires_idx`` index. Rolls + back on error so the manual-commit connection isn't left in an aborted-txn state. + """ + try: + with conn.cursor() as cur: + cur.execute("DELETE FROM pg_task_result WHERE expires_at <= now()") + deleted = cur.rowcount + conn.commit() + return deleted + except Exception: + _rollback_after_sweep_failure(conn, "pg_task_result") + raise + + +def sweep_orphan_dedup(conn: PgConnection, retention_seconds: int) -> int: + """Delete orphaned per-batch dedup markers older than *retention_seconds*. + + ``pg_batch_dedup`` markers are normally cleared on barrier teardown / reaper + barrier-recovery, but a partial-failure execution can leave them behind. A + marker only matters while its execution is in flight, so anything older than + the longest possible execution (*retention_seconds*) is a safe-to-drop orphan. + Filters on ``created_at``, which is deliberately **unindexed** (see + ``backend/pg_queue/models.py``), so each sweep seq-scans — fine at the 5-min + cadence on a normally-near-empty table; add an index if the dedup table grows. + Idempotent; rolls back on error. + """ + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM pg_batch_dedup " + "WHERE created_at <= now() - make_interval(secs => %s)", + (retention_seconds,), + ) + deleted = cur.rowcount + conn.commit() + return deleted + except Exception: + _rollback_after_sweep_failure(conn, "pg_batch_dedup") + raise + + def _execution_status( api_client: InternalAPIClient, execution_id: str, organization_id: str ) -> str | None: @@ -380,6 +501,8 @@ def __init__( lease: LeaderLeaseLike, *, interval_seconds: float | None = None, + sweep_interval_seconds: float | None = None, + dedup_retention_seconds: int | None = None, sweep_conn: PgConnection | None = None, api_client: InternalAPIClient | None = None, ) -> None: @@ -401,6 +524,29 @@ def __init__( f"lease window {lease.lease_seconds}s, or the leader loses the " f"lease between renews" ) + # Retention-sweep cadence + dedup-orphan horizon (validated like interval: + # an injected non-positive value reaches here unvalidated by the env parser). + self._sweep_interval = ( + sweep_interval_seconds + if sweep_interval_seconds is not None + else reaper_sweep_interval_from_env() + ) + if self._sweep_interval <= 0: + raise ValueError("sweep_interval_seconds must be positive") + self._dedup_retention = ( + dedup_retention_seconds + if dedup_retention_seconds is not None + else dedup_retention_from_env() + ) + if self._dedup_retention <= 0: + raise ValueError("dedup_retention_seconds must be positive") + # None → "never swept", so the first leader tick sweeps immediately; set to + # monotonic() each sweep so the cadence holds thereafter. (A None sentinel, + # not 0.0, so the gate doesn't lean on monotonic() never returning ~0.) + self._last_sweep_monotonic: float | None = None + # Per-table consecutive-failure streak — surfaced in the failure log so a + # persistently-failing sweep (and which table) is traceable in prod. + self._sweep_fail_streak: dict[str, int] = {} self._sweep_conn = sweep_conn self._owns_sweep_conn = sweep_conn is None # Lazily built so the reaper can be constructed without env/HTTP set up @@ -499,8 +645,68 @@ def tick(self) -> TickOutcome: except Exception: self._discard_owned_sweep_conn() raise + # Orchestrator's third job: retention cleanup (cadence-gated, so it does + # NOT run every tick). Last so a sweep error can't skip recovery/schedules. + self._maybe_sweep() return TickOutcome(was_leader=True, reclaimed=reclaimed) + def _maybe_sweep(self) -> None: + """Run the retention sweep at most once per ``_sweep_interval``. + + Leader-only (called from :meth:`tick` after recovery + schedules). Deletes + expired ``pg_task_result`` rows and orphaned ``pg_batch_dedup`` markers so + neither table grows unbounded as the gate ramps. The two sweeps run + **independently** (via :meth:`_run_sweep`): they cover different tables, so + a persistent fault in one must not skip — and then cadence-gate out — the + other. The cadence is advanced BEFORE sweeping so a failure waits one + interval before retry rather than hammering the DB every tick. + """ + now = time.monotonic() + if ( + self._last_sweep_monotonic is not None + and now - self._last_sweep_monotonic < self._sweep_interval + ): + return + self._last_sweep_monotonic = now + results = self._run_sweep("pg_task_result", sweep_expired_results) + dedup = self._run_sweep( + "pg_batch_dedup", + lambda conn: sweep_orphan_dedup(conn, self._dedup_retention), + ) + if results or dedup: + logger.info( + "Reaper: retention sweep deleted %s pg_task_result + " + "%s pg_batch_dedup row(s)", + results, + dedup, + ) + + def _run_sweep(self, table: str, fn: Callable[[PgConnection], int]) -> int: + """Run one retention sweep best-effort; return its row count (0 on failure). + + A cleanup failure is NOT propagated (it must not fail the tick) and must not + starve the sibling sweep — it is logged at this boundary with the table name + and a consecutive-failure streak (so a bloated ``pg_task_result`` / + ``pg_batch_dedup`` in prod is traceable, distinct from the generic + tick-failure log), and the owned conn is discarded so the next cycle + reconnects. A clean run resets the streak. + """ + try: + count = fn(self._get_sweep_conn()) + except Exception: + streak = self._sweep_fail_streak.get(table, 0) + 1 + self._sweep_fail_streak[table] = streak + logger.exception( + "Reaper: retention sweep of %s failed (%s consecutive) — will retry " + "after the next sweep interval", + table, + streak, + ) + self._discard_owned_sweep_conn() + return 0 + self._sweep_fail_streak[table] = 0 + return count + def run(self, *, install_signals: bool = True) -> None: """Lease-maintenance + recovery loop until stopped; releases on exit.""" self._running = True diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py index ab3b3bdf4d..01db9fda9a 100644 --- a/workers/tests/test_pg_reaper.py +++ b/workers/tests/test_pg_reaper.py @@ -15,6 +15,7 @@ from __future__ import annotations +import logging import os import threading import time @@ -27,10 +28,15 @@ from queue_backend.pg_queue.connection import create_pg_connection from queue_backend.pg_queue.reaper import ( PgReaper, + dedup_retention_from_env, reaper_interval_from_env, + reaper_sweep_interval_from_env, recover_expired_barriers, + sweep_expired_results, + sweep_orphan_dedup, ) + # The reaper's leader tick also runs the PG scheduler tick (②b). Its behaviour # is covered in test_pg_scheduler.py; stub it here by default so the leadership / # recovery / connection tests aren't coupled to a real schedule query on their @@ -43,6 +49,20 @@ def stub_scheduler_tick(monkeypatch): return mock +# The leader tick also runs the retention sweep (UN-3610). Stub the two sweep +# helpers by default so the leadership / connection tests don't hit a real DELETE +# on their dummy connections; the SQL-contract tests import the real helpers +# directly (unaffected by this module-attribute patch), and the sweep-wiring tests +# opt in via the returned mocks. +@pytest.fixture(autouse=True) +def stub_retention_sweep(monkeypatch): + results = MagicMock(return_value=0) + dedup = MagicMock(return_value=0) + monkeypatch.setattr(reaper_mod, "sweep_expired_results", results) + monkeypatch.setattr(reaper_mod, "sweep_orphan_dedup", dedup) + return SimpleNamespace(results=results, dedup=dedup) + + # --- Layer 1: env + construction (no DB) --- @@ -62,6 +82,39 @@ def test_invalid_raises(self, monkeypatch, bad): reaper_interval_from_env() +class TestSweepEnv: + def test_sweep_interval_default_and_override(self, monkeypatch): + monkeypatch.delenv("WORKER_PG_REAPER_SWEEP_SECONDS", raising=False) + assert reaper_sweep_interval_from_env() == pytest.approx(300.0) + monkeypatch.setenv("WORKER_PG_REAPER_SWEEP_SECONDS", "30") + assert reaper_sweep_interval_from_env() == pytest.approx(30.0) + + def test_dedup_retention_default_and_override(self, monkeypatch): + monkeypatch.delenv("WORKER_PG_DEDUP_RETENTION_SECONDS", raising=False) + assert dedup_retention_from_env() == 86400 + monkeypatch.setenv("WORKER_PG_DEDUP_RETENTION_SECONDS", "3600") + assert dedup_retention_from_env() == 3600 + + @pytest.mark.parametrize("bad", ["0", "-5", "abc"]) + def test_invalid_raises(self, monkeypatch, bad): + monkeypatch.setenv("WORKER_PG_REAPER_SWEEP_SECONDS", bad) + monkeypatch.setenv("WORKER_PG_DEDUP_RETENTION_SECONDS", bad) + with pytest.raises(ValueError): + reaper_sweep_interval_from_env() + with pytest.raises(ValueError): + dedup_retention_from_env() + + def test_cast_distinction_float_vs_int(self, monkeypatch): + # sweep parses as float, dedup as int — the cast is load-bearing. + monkeypatch.setenv("WORKER_PG_REAPER_SWEEP_SECONDS", "1.5") + assert reaper_sweep_interval_from_env() == pytest.approx(1.5) + monkeypatch.setenv("WORKER_PG_DEDUP_RETENTION_SECONDS", "1.5") + # int("1.5") rejects the fractional value; the message names the real cause + # ("cannot be parsed: …") rather than the misleading "is not a number". + with pytest.raises(ValueError, match="cannot be parsed"): + dedup_retention_from_env() + + class _FakeLease: """Duck-typed LeaderLease. ``acquires``/``renews`` accept a bool (constant) or a list (one outcome popped per call, then ``False``). @@ -106,6 +159,21 @@ def test_non_positive_interval_rejected(self): def test_valid_interval_accepted(self): PgReaper(_FakeLease(lease_seconds=10), interval_seconds=3, sweep_conn=object()) + def test_non_positive_sweep_interval_rejected(self): + # An injected knob bypasses the env parser's guard → re-validated in __init__. + with pytest.raises(ValueError, match="sweep_interval"): + PgReaper( + _FakeLease(), interval_seconds=1, sweep_interval_seconds=0, + sweep_conn=object(), + ) + + def test_non_positive_dedup_retention_rejected(self): + with pytest.raises(ValueError, match="dedup_retention"): + PgReaper( + _FakeLease(), interval_seconds=1, dedup_retention_seconds=0, + sweep_conn=object(), + ) + # --- Layer 2: leadership gating (fake lease + patched sweep, no DB) --- @@ -249,6 +317,178 @@ def test_scheduler_error_discards_owned_conn(self, stub_scheduler_tick): assert reaper._sweep_conn is None # discarded +class TestRetentionSweepSql: + """The sweep helpers' SQL contract (mock cursor, no DB). These call the real + helpers (imported at module load), unaffected by the autouse stub which patches + the module attribute the reaper looks up at call time. + """ + + @staticmethod + def _conn_cur(rowcount): + cur = MagicMock() + cur.rowcount = rowcount + conn = MagicMock() + conn.cursor.return_value.__enter__.return_value = cur + return conn, cur + + def test_sweep_expired_results_sql(self): + conn, cur = self._conn_cur(3) + assert sweep_expired_results(conn) == 3 + sql = cur.execute.call_args[0][0] + assert "DELETE FROM pg_task_result" in sql and "expires_at <= now()" in sql + conn.commit.assert_called_once() + + def test_sweep_orphan_dedup_sql(self): + conn, cur = self._conn_cur(2) + assert sweep_orphan_dedup(conn, 999) == 2 + args = cur.execute.call_args[0] + assert "DELETE FROM pg_batch_dedup" in args[0] + assert "created_at <= now() - make_interval" in args[0] + assert args[1] == (999,) # the retention param is bound, not interpolated + conn.commit.assert_called_once() + + @pytest.mark.parametrize( + "sweep", + [ + lambda conn: sweep_expired_results(conn), + lambda conn: sweep_orphan_dedup(conn, 60), + ], + ids=["expired_results", "orphan_dedup"], + ) + def test_sweep_rolls_back_on_error(self, sweep): + # Both helpers have their own try/except/rollback — exercise each. + cur = MagicMock() + cur.execute.side_effect = psycopg2.OperationalError("dead") + conn = MagicMock() + conn.cursor.return_value.__enter__.return_value = cur + with pytest.raises(psycopg2.OperationalError): + sweep(conn) + conn.rollback.assert_called_once() + conn.commit.assert_not_called() + + def test_rollback_failure_is_logged_and_original_error_raised(self, caplog): + # If rollback itself raises, surface it (don't swallow) but still propagate + # the ORIGINAL DELETE error, not the rollback's. + cur = MagicMock() + cur.execute.side_effect = psycopg2.OperationalError("dead") + conn = MagicMock() + conn.cursor.return_value.__enter__.return_value = cur + conn.rollback.side_effect = psycopg2.InterfaceError("conn dead") + with caplog.at_level(logging.WARNING, logger="queue_backend.pg_queue.reaper"): + with pytest.raises(psycopg2.OperationalError): + sweep_expired_results(conn) + assert "rollback after a failed pg_task_result sweep also failed" in caplog.text + + +class TestRetentionSweepTick: + """The sweep runs leader-only, after recovery + schedule, cadence-gated.""" + + def _reaper(self, lease, **kw): + kw.setdefault("sweep_interval_seconds", 300) + kw.setdefault("dedup_retention_seconds", 86400) + return PgReaper( + lease, interval_seconds=0.01, sweep_conn=object(), api_client=object(), **kw + ) + + def test_leader_sweeps(self, stub_retention_sweep): + reaper = self._reaper(_FakeLease(acquires=True, renews=True)) + conn = reaper._sweep_conn # capture once (don't re-fetch in the assertion) + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): + reaper.tick() + stub_retention_sweep.results.assert_called_once_with(conn) + stub_retention_sweep.dedup.assert_called_once_with(conn, 86400) + + def test_standby_does_not_sweep(self, stub_retention_sweep): + reaper = self._reaper(_FakeLease(acquires=False)) + with patch.object(reaper_mod, "recover_expired_barriers"): + reaper.tick() + stub_retention_sweep.results.assert_not_called() + stub_retention_sweep.dedup.assert_not_called() + + def test_cadence_gates_repeat_within_interval(self, stub_retention_sweep): + # Two leader ticks well within the 300s interval → swept once, not twice. + reaper = self._reaper(_FakeLease(acquires=True, renews=True)) + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): + reaper.tick() + reaper.tick() + assert stub_retention_sweep.results.call_count == 1 + assert stub_retention_sweep.dedup.call_count == 1 + + def test_runs_after_recovery_and_schedule( + self, stub_scheduler_tick, stub_retention_sweep + ): + order = [] + reaper = self._reaper(_FakeLease(acquires=True, renews=True)) + stub_scheduler_tick.side_effect = lambda *_: order.append("schedule") + stub_retention_sweep.results.side_effect = lambda *_: order.append("sweep") or 0 + with patch.object( + reaper_mod, + "recover_expired_barriers", + side_effect=lambda *_: order.append("recover") or [], + ): + reaper.tick() + assert order == ["recover", "schedule", "sweep"] + + def test_one_sweep_failing_does_not_starve_the_other( + self, stub_retention_sweep, caplog + ): + # [High] independence: a failing pg_task_result sweep must NOT skip the + # pg_batch_dedup sweep, must NOT propagate (cleanup mustn't fail the tick), + # and must discard the owned conn so the sibling reconnects. + reaper = PgReaper( + _FakeLease(acquires=True, renews=True), + interval_seconds=0.01, + sweep_interval_seconds=300, + api_client=object(), + ) + owned = MagicMock(closed=False) + reaper._sweep_conn = owned + stub_retention_sweep.results.side_effect = psycopg2.OperationalError("db gone") + with ( + patch.object( + reaper_mod, "create_pg_connection", return_value=MagicMock(closed=False) + ) as reconnect, + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.reaper"), + ): + reaper.tick() # must NOT raise + stub_retention_sweep.results.assert_called_once() # attempted + failed + stub_retention_sweep.dedup.assert_called_once() # sibling still ran + reconnect.assert_called_once() # reconnected after the discard + assert "retention sweep of pg_task_result failed (1 consecutive)" in caplog.text + + def test_failing_sweep_still_advances_cadence(self, stub_retention_sweep): + # The stamp is advanced before the sweep, so a failure waits one interval + # before retry (no DB hammering) — a second immediate tick is gated out. + reaper = self._reaper(_FakeLease(acquires=True, renews=True)) + stub_retention_sweep.results.side_effect = RuntimeError("boom") + with patch.object(reaper_mod, "recover_expired_barriers", return_value=[]): + reaper.tick() + reaper.tick() + assert stub_retention_sweep.results.call_count == 1 # not retried immediately + + def test_logs_counts_only_when_rows_deleted(self, stub_retention_sweep, caplog): + reaper = self._reaper(_FakeLease(acquires=True, renews=True)) + stub_retention_sweep.results.return_value = 0 + stub_retention_sweep.dedup.return_value = 4 # one non-zero → still logs + with ( + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + caplog.at_level(logging.INFO, logger="queue_backend.pg_queue.reaper"), + ): + reaper.tick() + assert "deleted 0 pg_task_result + 4 pg_batch_dedup row(s)" in caplog.text + + def test_no_log_when_nothing_deleted(self, stub_retention_sweep, caplog): + reaper = self._reaper(_FakeLease(acquires=True, renews=True)) + # both stubs default to return_value=0 + with ( + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + caplog.at_level(logging.INFO, logger="queue_backend.pg_queue.reaper"), + ): + reaper.tick() + assert "retention sweep deleted" not in caplog.text + + # --- Layer 3: connection handling (mocked, no DB) --- From 20c6e0bc539af04e03d034d9a8ac0f3a728136ce Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:45:40 +0530 Subject: [PATCH 33/65] =?UTF-8?q?UN-3607=20[REFACTOR]=20retire=20the=20exe?= =?UTF-8?q?cutor=5Frpc=20mirror=20=E2=80=94=20shared=20dispatcher=20+=20in?= =?UTF-8?q?jected=20transport=20(#2102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3607 [REFACTOR] retire the executor_rpc mirror — shared dispatcher + injected transport The backend and workers carried byte-for-byte mirrors of the executor-RPC dispatch (gate + reply_key/timeout orchestration + routing); the only real difference is the transport primitive (Django ORM vs psycopg2). Lift the shared logic into unstract.workflow_execution.executor_rpc (which both already depend on; it can't live in unstract.core because sdk1 imports core → circular) and inject the differing primitive via a QueueTransport Protocol — composition, not inheritance. - shared: PgExecutionDispatcher (concrete dispatch/async/with_callback + never-raises) calling transport.enqueue / wait_for_result; ExecResultRow (normalised result row); resolve_pg_transport (master-gate value supplied by caller, then Flipt); RoutingExecutionDispatcher (per-call PG-vs-Celery, injected celery/pg/resolve). - backend adapter: DjangoQueueTransport (enqueue_task + PgTaskResult poll) + settings master-gate + factory. - workers adapter: PgClientQueueTransport (PgQueueClient + PgResultBackend) + env master-gate + factory. ~600 duplicated lines collapse to one base + two ~40-line adapters → clears the SonarCloud duplication gate permanently. Behaviour is byte-identical (gate, routing, never-raises): zero regression. The dispatch contract + routing + gate matrix are tested ONCE against a fake transport (workers suite); each side's tests shrink to its adapter + factory wiring. Both call sites use the unchanged get_executor_dispatcher. Co-Authored-By: Claude Opus 4.8 * [FIX] vite dev proxy — forward websocket upgrades to the backend (local dev only) The Socket.IO log/result channel connects to /api/v1/socket with a websocket-only transport, but the Vite dev proxy only forwarded /api HTTP (no `ws: true`), so the upgrade never reached the backend and Prompt Studio results never streamed to the UI in local dev. ws-proxying was dropped in the CRA→Vite migration (the stale setupProxy.js comment is the leftover). Dev-server only: `server.proxy` runs solely under `vite dev`; staging/prod serve a built bundle behind nginx/Traefik (which already routes /api/v1/socket), so this has no effect on any deployed environment. Rides the UN-3607 PR. Co-Authored-By: Claude Opus 4.8 * UN-3607 [FIX] drop the unused headers param from the PG dispatcher (SonarCloud S1172) The PG path carries org/routing in the enqueue payload, not Celery headers, and the RoutingExecutionDispatcher strips fairness headers before delegating to the PG dispatcher — so headers was accepted-but-ignored on dispatch/dispatch_async/ dispatch_with_callback. SonarCloud flagged it as unused (L269/L297). Removed from all three for consistency; the RoutingExecutionDispatcher (the SDK-substitutable boundary) keeps headers on its public methods and forwards them only to Celery. Backend 7 + workers 36 green. Co-Authored-By: Claude Opus 4.8 * UN-3607 [FIX] address review — Protocol conformance, type the seam, de-dup the poll loop Toolkit review on #2102 (all 14): - Both transports now explicitly inherit QueueTransport so a type-checker verifies each implementation against the seam (not just at the construction site). - CallbackSignature Protocol in unstract.core types signature_to_continuation + dispatch_with_callback params (was Any); _CeleryDispatcher Protocol types the router's celery dependency (was Any). - Extracted the backend poll loop into a shared poll_for_row(fetch, timeout, between_polls=...) — the one duplicated logic that survived. - dispatch gets a docstring; the fire-and-forget enqueues log-before-raise (+ a note that a raised enqueue IS the failure signal since on_error can't fire); a COMPLETED row with no result is a distinct error; reworded the "strips headers" comment. - Workers master-gate warns on a non-true/false value (was a silent no-op). - Lint (I001/D209); tests: on_error translation + default task_id, dispatch_async propagates, backend multi-iteration poll. Backend 8 + workers 38 green. Co-Authored-By: Claude Opus 4.8 * UN-3607 [FIX] greptile — unify the PG poll loop (move poll_for_row to unstract.core) greptile P2: poll_for_row was backend-only — the workers PgResultBackend.wait_for_result still had its own identical backoff loop, so the constants lived in two places. Since poll_for_row is pure (no Django/psycopg/SDK deps), moved it to unstract.core.polling where BOTH PG result pollers import it: the backend DjangoQueueTransport and the workers PgResultBackend. The backoff now lives in exactly one place to tune. - new unstract.core.polling.poll_for_row (the shared backoff skeleton) - workflow_execution.executor_rpc: dropped poll_for_row (+ its time/TypeVar imports) - backend adapter + PgResultBackend.wait_for_result both delegate to it - behaviour-identical: backend 8 + workers 38 + result_backend 7 (real-PG) green Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/pg_queue/executor_rpc.py | 375 +++--------- backend/pg_queue/tests/test_executor_rpc.py | 381 ++++--------- backend/uv.lock | 2 + frontend/vite.config.js | 6 + .../src/unstract/core/execution_dispatch.py | 22 +- unstract/core/src/unstract/core/polling.py | 48 ++ unstract/workflow-execution/pyproject.toml | 2 + .../workflow_execution/executor_rpc.py | 467 +++++++++++++++ .../queue_backend/pg_queue/executor_rpc.py | 430 +++----------- .../queue_backend/pg_queue/result_backend.py | 28 +- workers/tests/test_executor_rpc.py | 533 ++++++++---------- workers/uv.lock | 2 + 12 files changed, 1037 insertions(+), 1259 deletions(-) create mode 100644 unstract/core/src/unstract/core/polling.py create mode 100644 unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py diff --git a/backend/pg_queue/executor_rpc.py b/backend/pg_queue/executor_rpc.py index 72c0895a35..c50850b2e9 100644 --- a/backend/pg_queue/executor_rpc.py +++ b/backend/pg_queue/executor_rpc.py @@ -1,356 +1,117 @@ -"""Executor-RPC transport routing for the PG path (Phase 9). +"""Executor-RPC for the PG path — backend (Django) transport adapter. -The executor "RPC" is a synchronous request-reply: a caller (prompt-studio here) -sends an ``ExecutionContext`` to the executor worker and blocks for the -``ExecutionResult``. The legacy transport is Celery — the SDK -``ExecutionDispatcher`` (``send_task`` + ``AsyncResult.get``). This module adds a -**parallel** Postgres transport that leaves Celery and the SDK completely -untouched (no SDK edit, no change to the ``execute_extraction`` task or the -Celery executor worker): +The gate + reply_key/timeout orchestration + routing live ONCE in +``unstract.workflow_execution.executor_rpc`` (shared with the workers). This module +is the thin Django half: a :class:`DjangoQueueTransport` that enqueues via the ORM +(``enqueue_task``) and polls ``PgTaskResult``, plus the per-side gate (master switch = +``settings.PG_QUEUE_TRANSPORT_ENABLED``) and the :func:`get_executor_dispatcher` +factory that wires them together. -- :class:`PgExecutionDispatcher` enqueues ``execute_extraction`` onto the PG queue - with a unique ``reply_key`` and polls ``pg_task_result`` for the reply — same - ``.dispatch()`` contract as the SDK dispatcher (never raises; failure/timeout → - ``ExecutionResult.failure``). -- :func:`resolve_executor_transport` is the gate: master - ``PG_QUEUE_TRANSPORT_ENABLED`` then the **single** Flipt flag - ``pg_queue_enabled`` — the same flag the execution path uses, so one - flip turns the whole PG-queue feature on/off (no per-subsystem flags to - maintain). Fails closed to Celery. -- :class:`RoutingExecutionDispatcher` is what callers get from - ``PromptStudioHelper._get_dispatcher()``: ``dispatch()`` picks PG-vs-Celery - **per call** (read at dispatch time → flipping the flag is an instant, - no-redeploy rollout/rollback); ``dispatch_async`` / ``dispatch_with_callback`` - always delegate to Celery (the callback path is a later slice). - -Zero-regression: gate off ⇒ every method delegates to the unchanged Celery -``ExecutionDispatcher`` and no ``pg_task_result`` row is created. +Zero-regression: gate off ⇒ the routing dispatcher delegates every mode to the +unchanged Celery ``ExecutionDispatcher`` and no ``pg_task_result`` row is created. """ from __future__ import annotations -import logging -import os -import time -import uuid -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from django.conf import settings from django.db import close_old_connections -from pg_queue.flags import PG_QUEUE_FLAG_KEY from pg_queue.models import PgTaskResult from pg_queue.producer import enqueue_task -from unstract.core.data_models import PgTaskStatus -from unstract.core.execution_dispatch import DispatchHandle, signature_to_continuation -from unstract.flags.feature_flag import check_feature_flag_status +from unstract.core.polling import poll_for_row from unstract.sdk1.execution.dispatcher import ExecutionDispatcher -from unstract.sdk1.execution.result import ExecutionResult +from unstract.workflow_execution.executor_rpc import ( + EXECUTE_TASK, + ExecResultRow, + PgExecutionDispatcher, + QueueTransport, + RoutingExecutionDispatcher, + resolve_pg_transport, +) if TYPE_CHECKING: + from unstract.core.data_models import ContinuationSpec from unstract.sdk1.execution.context import ExecutionContext -logger = logging.getLogger(__name__) - -# Gating reads the single shared PG-queue flag (pg_queue.flags.PG_QUEUE_FLAG_KEY, -# imported above) — the same key execution and the scheduler use. -_EXECUTE_TASK = "execute_extraction" -# Mirror the SDK's queue-per-executor convention so the PG executor queue name -# matches the Celery one (the queue routes by the row's queue_name column). -_QUEUE_PREFIX = "celery_executor_" -# Caller-side wait default — mirrors the SDK dispatcher (EXECUTOR_RESULT_TIMEOUT -# env, else 3600s) so a PG-routed caller waits exactly as long as a Celery one. -_DEFAULT_TIMEOUT_ENV = "EXECUTOR_RESULT_TIMEOUT" -_DEFAULT_TIMEOUT = 3600 -_POLL_INITIAL_SECONDS = 0.2 -_POLL_MAX_SECONDS = 2.0 +# Re-exported so existing ``from pg_queue.executor_rpc import …`` imports keep working. +__all__ = [ + "DjangoQueueTransport", + "PgExecutionDispatcher", + "RoutingExecutionDispatcher", + "get_executor_dispatcher", + "resolve_executor_transport", +] def resolve_executor_transport(context: ExecutionContext) -> bool: """True → route this executor dispatch over PG; False → Celery (default). - Mirrors ``resolve_transport``: master-gated by ``PG_QUEUE_TRANSPORT_ENABLED``, - then the **single** ``pg_queue_enabled`` Flipt flag (shared across - the whole PG-queue feature), bucketed per org. **Fails closed to Celery** on a - closed gate, a blind Flipt, or any error — so the executor never silently - loses its transport. + The backend gate: master switch ``settings.PG_QUEUE_TRANSPORT_ENABLED``, then the + shared Flipt eval (single ``pg_queue_enabled`` flag, fail-closed). """ - if not settings.PG_QUEUE_TRANSPORT_ENABLED: - return False - if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": - logger.warning( - "resolve_executor_transport: gate ON but FLIPT_SERVICE_AVAILABLE != " - "true (Flipt blind); using Celery" - ) - return False - org = getattr(context, "organization_id", None) - # %-bucket keyed on org (prompt-studio is org-scoped); fall back to run_id so - # a context without an org still resolves deterministically. - entity_id = str(org or getattr(context, "run_id", "") or "default") - flag_context = {"executor_name": str(context.executor_name)} - if org: - flag_context["organization_id"] = str(org) - try: - enabled = check_feature_flag_status( - flag_key=PG_QUEUE_FLAG_KEY, entity_id=entity_id, context=flag_context - ) - except Exception: - logger.warning( - "resolve_executor_transport: Flipt check failed; using Celery", - exc_info=True, - ) - return False - return bool(enabled) + return resolve_pg_transport( + context, master_gate_enabled=settings.PG_QUEUE_TRANSPORT_ENABLED + ) -class PgExecutionDispatcher: - """PG request-reply executor dispatch — drop-in for ``ExecutionDispatcher.dispatch``. +class DjangoQueueTransport(QueueTransport): + """:class:`QueueTransport` over the Django ORM (the backend half). - Enqueues ``execute_extraction`` with a unique ``reply_key`` and blocks on - ``pg_task_result`` until the executor consumer records the result or the - timeout elapses. Honours the same contract as the SDK dispatcher: it never - raises and converts a timeout/failure into ``ExecutionResult.failure`` so - callers can branch on ``result.success`` identically on either transport. + Inherits the Protocol so a type-checker verifies this implementation against the + seam independently of the ``PgExecutionDispatcher(...)`` construction site. """ - def dispatch( + def enqueue( self, + *, + queue: str, context: ExecutionContext, - timeout: int | None = None, - ) -> ExecutionResult: - if timeout is None: - # Guard the env parse so a misconfigured EXECUTOR_RESULT_TIMEOUT can't - # raise out of dispatch() (the never-raises contract). - try: - timeout = int(os.environ.get(_DEFAULT_TIMEOUT_ENV, _DEFAULT_TIMEOUT)) - except (TypeError, ValueError): - timeout = _DEFAULT_TIMEOUT - reply_key = str(uuid.uuid4()) - queue = f"{_QUEUE_PREFIX}{context.executor_name}" - org = getattr(context, "organization_id", "") or "" - try: - enqueue_task( - task_name=_EXECUTE_TASK, - queue=queue, - args=[context.to_dict()], - org_id=str(org), - reply_key=reply_key, - ) - except Exception as exc: - logger.exception( - "PG executor dispatch: enqueue failed (executor=%s run_id=%s)", - context.executor_name, - context.run_id, - ) - return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}") - logger.info( - "PG executor dispatch: enqueued reply_key=%s queue=%s run_id=%s " - "timeout=%ss; waiting for result...", - reply_key, - queue, - context.run_id, - timeout, - ) - row = self._wait_for_result(reply_key, timeout) - if row is None: - logger.warning( - "PG executor dispatch: TIMEOUT after %ss (reply_key=%s run_id=%s) — " - "the executor task may still be running", - timeout, - reply_key, - context.run_id, - ) - return ExecutionResult.failure( - error=f"TimeoutError: executor reply not received within {timeout}s" - ) - if row.status == PgTaskStatus.COMPLETED.value and row.result is not None: - try: - return ExecutionResult.from_dict(row.result) - except Exception: - # Honour the never-raises contract: a malformed completed row - # becomes a failure result, not a 500 to the caller. - logger.exception( - "PG executor dispatch: malformed completed result " - "(reply_key=%s run_id=%s)", - reply_key, - context.run_id, - ) - return ExecutionResult.failure( - error=f"Malformed executor result for reply_key {reply_key}" - ) - logger.warning( - "PG executor dispatch: executor reported failure (reply_key=%s " - "run_id=%s): %s", - reply_key, - context.run_id, - row.error or "(no error)", - ) - return ExecutionResult.failure(error=row.error or "executor task failed") - - def dispatch_async( - self, context: ExecutionContext, headers: dict[str, Any] | None = None - ) -> str: - """Fire-and-forget enqueue of ``execute_extraction``; returns the task id. - - The PG analogue of the SDK ``dispatch_async``: no ``reply_key``, no - callback, no blocking. There is no PG ``AsyncResult`` backend, so a caller - that needs the outcome uses :meth:`dispatch_with_callback` (a self-chained - continuation), not polling on this id. ``headers`` is accepted and ignored - (PG carries routing in the payload). Enqueue failures propagate — parity - with the SDK, which lets a broker error out of ``dispatch_async``. - """ - task_id = str(uuid.uuid4()) - queue = f"{_QUEUE_PREFIX}{context.executor_name}" - org = getattr(context, "organization_id", "") or "" - enqueue_task( - task_name=_EXECUTE_TASK, - queue=queue, - args=[context.to_dict()], - org_id=str(org), - task_id=task_id, - ) - logger.info( - "PG executor dispatch_async: enqueued task_id=%s queue=%s run_id=%s", - task_id, - queue, - context.run_id, - ) - return task_id - - def dispatch_with_callback( - self, - context: ExecutionContext, - on_success: Any | None = None, - on_error: Any | None = None, + org_id: str, + reply_key: str | None = None, + on_success: ContinuationSpec | None = None, + on_error: ContinuationSpec | None = None, task_id: str | None = None, - headers: dict[str, Any] | None = None, - ) -> DispatchHandle: - """Fire-and-forget enqueue with self-chained callbacks (§5 model). - - The PG analogue of the SDK ``dispatch_with_callback``: instead of Celery - ``link`` / ``link_error`` (which the broker fires), the on-success / - on-error Celery ``Signature``s are translated to serialisable - :class:`ContinuationSpec`s and carried in the payload. After the executor - consumer runs ``execute_extraction`` it self-chains the matching - continuation onto the callback queue. Returns a :class:`DispatchHandle` - exposing ``.id`` (== ``task_id``) so call sites read the task id exactly - as on the Celery path. ``headers`` is accepted and ignored. - """ - task_id = task_id or str(uuid.uuid4()) - queue = f"{_QUEUE_PREFIX}{context.executor_name}" - org = getattr(context, "organization_id", "") or "" - success_spec = signature_to_continuation(on_success) - error_spec = signature_to_continuation(on_error) + ) -> None: enqueue_task( - task_name=_EXECUTE_TASK, + task_name=EXECUTE_TASK, queue=queue, args=[context.to_dict()], - org_id=str(org), - on_success=success_spec, - on_error=error_spec, + org_id=org_id, + reply_key=reply_key, + on_success=on_success, + on_error=on_error, task_id=task_id, ) - logger.info( - "PG executor dispatch_with_callback: enqueued task_id=%s queue=%s " - "run_id=%s on_success=%s on_error=%s", - task_id, - queue, - context.run_id, - success_spec["task_name"] if success_spec else None, - error_spec["task_name"] if error_spec else None, - ) - return DispatchHandle(task_id) - @staticmethod - def _wait_for_result(reply_key: str, timeout: float) -> PgTaskResult | None: + def wait_for_result(self, reply_key: str, timeout: float) -> ExecResultRow | None: """Poll ``pg_task_result`` until the row appears or *timeout* elapses. - Poll-based with capped backoff (PgBouncer-safe; no LISTEN/NOTIFY). The DB - connection is released between polls (``close_old_connections``) so a - long-running RPC does not pin a backend connection for its whole duration - and exhaust the pool. Each poll is its own autocommit query, so a row - committed by the executor consumer becomes visible — **dispatch must NOT - be called inside an open transaction** (``transaction.atomic`` / - ``ATOMIC_REQUESTS`` would pin one snapshot and never see the new row). + Uses the shared :func:`poll_for_row` backoff skeleton, releasing the DB + connection between polls (``close_old_connections``) so a long-running RPC + does not pin a backend connection and exhaust the pool. Each poll is its own + autocommit query, so a row committed by the executor consumer becomes visible + — **dispatch must NOT be called inside an open transaction** + (``transaction.atomic`` / ``ATOMIC_REQUESTS`` would pin one snapshot and never + see the new row). """ - deadline = time.monotonic() + timeout - delay = _POLL_INITIAL_SECONDS - while True: + + def _fetch() -> ExecResultRow | None: row = PgTaskResult.objects.filter(pk=reply_key).first() - if row is not None: - return row - remaining = deadline - time.monotonic() - if remaining <= 0: + if row is None: return None - # Don't hold the connection idle through the sleep. - close_old_connections() - time.sleep(min(delay, remaining)) - delay = min(delay * 2, _POLL_MAX_SECONDS) - - -class RoutingExecutionDispatcher: - """Gate-routed executor dispatcher returned by ``_get_dispatcher()``. - - Every mode chooses PG vs Celery per call (instant rollout/rollback): - ``dispatch()`` (request-reply), ``dispatch_async`` (fire-and-forget) and - ``dispatch_with_callback`` (self-chained callbacks). Duck-typed against the SDK - ``ExecutionDispatcher`` so call sites are unchanged. - """ - - def __init__(self, celery_app: object | None = None) -> None: - self._celery = ExecutionDispatcher(celery_app=celery_app) - self._pg = PgExecutionDispatcher() - - def dispatch( - self, - context: ExecutionContext, - timeout: int | None = None, - headers: dict[str, Any] | None = None, - ) -> ExecutionResult: - if resolve_executor_transport(context): - logger.info( - "Executor RPC → PG transport (executor=%s run_id=%s)", - context.executor_name, - context.run_id, - ) - # PG carries fairness via the enqueue payload, not Celery headers, so - # the headers (fairness key) are intentionally not forwarded here. - return self._pg.dispatch(context, timeout=timeout) - return self._celery.dispatch(context, timeout=timeout, headers=headers) - - def dispatch_async( - self, context: ExecutionContext, headers: dict[str, Any] | None = None - ) -> str: - if resolve_executor_transport(context): - return self._pg.dispatch_async(context) - return self._celery.dispatch_async(context, headers=headers) + return ExecResultRow(status=row.status, result=row.result, error=row.error) - def dispatch_with_callback( - self, - context: ExecutionContext, - on_success: Any | None = None, - on_error: Any | None = None, - task_id: str | None = None, - headers: dict[str, Any] | None = None, - ) -> Any: - if resolve_executor_transport(context): - return self._pg.dispatch_with_callback( - context, - on_success=on_success, - on_error=on_error, - task_id=task_id, - ) - return self._celery.dispatch_with_callback( - context, - on_success=on_success, - on_error=on_error, - task_id=task_id, - headers=headers, - ) + return poll_for_row(_fetch, timeout, between_polls=close_old_connections) def get_executor_dispatcher( celery_app: object | None = None, ) -> RoutingExecutionDispatcher: """Factory: the gate-routed executor dispatcher (PG when enabled, else Celery).""" - return RoutingExecutionDispatcher(celery_app=celery_app) + return RoutingExecutionDispatcher( + celery=ExecutionDispatcher(celery_app=celery_app), + pg=PgExecutionDispatcher(DjangoQueueTransport()), + resolve=resolve_executor_transport, + ) diff --git a/backend/pg_queue/tests/test_executor_rpc.py b/backend/pg_queue/tests/test_executor_rpc.py index 7f5a2ed84f..1361b64f09 100644 --- a/backend/pg_queue/tests/test_executor_rpc.py +++ b/backend/pg_queue/tests/test_executor_rpc.py @@ -1,302 +1,125 @@ -"""Tests for the executor-RPC transport routing (Phase 9). - -DB-free: settings / Flipt / the sub-dispatchers are mocked. Pins the gate's -fail-closed matrix and — the load-bearing zero-regression property — that with -the gate off ``RoutingExecutionDispatcher`` delegates EVERY mode to the unchanged -Celery ``ExecutionDispatcher`` and never touches the PG path. +"""Tests for the backend executor-RPC adapter (UN-3607). + +The dispatch contract (never-raises / result interpretation), the routing, and the +Flipt gate matrix live ONCE in the shared module and are covered in +``workers/tests/test_executor_rpc.py`` against a fake transport. This suite pins the +**backend half**: the :class:`DjangoQueueTransport` (enqueue via the ORM, poll +``PgTaskResult`` → ``ExecResultRow``), the per-side gate (master switch = +``settings.PG_QUEUE_TRANSPORT_ENABLED``), and the factory wiring. """ from unittest.mock import MagicMock, patch +from unstract.workflow_execution.executor_rpc import ExecResultRow + from pg_queue.executor_rpc import ( - PgExecutionDispatcher, + DjangoQueueTransport, RoutingExecutionDispatcher, + get_executor_dispatcher, resolve_executor_transport, ) _MOD = "pg_queue.executor_rpc" -def _completed(result: dict) -> MagicMock: - return MagicMock(status="completed", result=result, error="") - - -def _ok_result() -> dict: - return {"success": True, "data": {"x": 1}, "metadata": {}, "error": None} - - -class TestPgExecutionDispatcherDispatch: - """The load-bearing contract: never raises; timeout/failure → failure result. - - DB-free — ``enqueue_task`` and ``_wait_for_result`` are mocked. - """ - - @staticmethod - def _ctx() -> MagicMock: - c = MagicMock() - c.executor_name = "legacy" - c.run_id = "r" - c.organization_id = "o" - c.to_dict.return_value = {"run_id": "r"} - return c - - def test_enqueue_failure_returns_failure_not_raise(self): - with patch(f"{_MOD}.enqueue_task", side_effect=RuntimeError("db down")): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - assert "RuntimeError" in res.error - - def test_timeout_returns_failure(self): - with ( - patch(f"{_MOD}.enqueue_task"), - patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=None), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=3) - assert res.success is False - assert "within 3s" in res.error - - def test_completed_row_returns_result(self): - with ( - patch(f"{_MOD}.enqueue_task"), - patch.object( - PgExecutionDispatcher, "_wait_for_result", - return_value=_completed(_ok_result()), - ), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is True - - def test_failed_row_returns_error(self): - row = MagicMock(status="failed", result=None, error="boom") - with ( - patch(f"{_MOD}.enqueue_task"), - patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - assert res.error == "boom" - - def test_failed_row_empty_error_falls_back(self): - row = MagicMock(status="failed", result=None, error="") - with ( - patch(f"{_MOD}.enqueue_task"), - patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - assert "executor task failed" in res.error - - def test_completed_but_result_none_is_failure(self): - row = MagicMock(status="completed", result=None, error="") - with ( - patch(f"{_MOD}.enqueue_task"), - patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - - def test_malformed_completed_row_is_failure_not_raise(self): - with ( - patch(f"{_MOD}.enqueue_task"), - patch.object( - PgExecutionDispatcher, "_wait_for_result", - return_value=_completed({"bad": "shape"}), - ), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - assert "Malformed" in res.error - - def test_timeout_none_reads_env_then_default(self, monkeypatch): - monkeypatch.setenv("EXECUTOR_RESULT_TIMEOUT", "42") - seen = {} - - def fake_wait(reply_key, timeout): - seen["timeout"] = timeout - return None - - with ( - patch(f"{_MOD}.enqueue_task"), - patch.object( - PgExecutionDispatcher, "_wait_for_result", side_effect=fake_wait - ), - ): - # No explicit timeout arg → falls back to the env/default. - PgExecutionDispatcher().dispatch(self._ctx()) - assert seen["timeout"] == 42 - - def test_timeout_none_bad_env_falls_back_to_default(self, monkeypatch): - monkeypatch.setenv("EXECUTOR_RESULT_TIMEOUT", "not-an-int") - seen = {} - - def fake_wait(reply_key, timeout): - seen["timeout"] = timeout - return None - - with ( - patch(f"{_MOD}.enqueue_task"), - patch.object( - PgExecutionDispatcher, "_wait_for_result", side_effect=fake_wait - ), - ): - PgExecutionDispatcher().dispatch(self._ctx()) # must not raise - assert seen["timeout"] == 3600 # _DEFAULT_TIMEOUT - - -def _ctx(org: str | None = "org1") -> MagicMock: +def _ctx() -> MagicMock: c = MagicMock() c.executor_name = "legacy" - c.run_id = "run-1" - c.organization_id = org + c.run_id = "r" + c.to_dict.return_value = {"run_id": "r"} return c -def _gate(on: bool): - s = MagicMock() - s.PG_QUEUE_TRANSPORT_ENABLED = on - return patch(f"{_MOD}.settings", s) - - -class TestResolveExecutorTransport: - def test_master_gate_off_is_celery(self, monkeypatch): - monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with _gate(False), patch(f"{_MOD}.check_feature_flag_status") as flag: - assert resolve_executor_transport(_ctx()) is False - flag.assert_not_called() # gate off → Flipt never consulted - - def test_flipt_unavailable_is_celery(self, monkeypatch): - monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") - with _gate(True), patch(f"{_MOD}.check_feature_flag_status") as flag: - assert resolve_executor_transport(_ctx()) is False - flag.assert_not_called() - - def test_flag_true_is_pg_keyed_on_org(self, monkeypatch): - monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with _gate(True), patch( - f"{_MOD}.check_feature_flag_status", return_value=True - ) as flag: - assert resolve_executor_transport(_ctx("orgX")) is True - assert flag.call_args.kwargs["entity_id"] == "orgX" - # The single shared PG-queue flag (not a per-subsystem flag). - assert flag.call_args.kwargs["flag_key"] == "pg_queue_enabled" - - def test_flag_false_is_celery(self, monkeypatch): - monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with _gate(True), patch( - f"{_MOD}.check_feature_flag_status", return_value=False - ): - assert resolve_executor_transport(_ctx()) is False - - def test_flipt_error_fails_closed_to_celery(self, monkeypatch): - monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with _gate(True), patch( - f"{_MOD}.check_feature_flag_status", side_effect=RuntimeError("down") +class TestDjangoQueueTransportEnqueue: + def test_enqueue_calls_enqueue_task_with_request_reply_fields(self): + with patch(f"{_MOD}.enqueue_task") as enq: + DjangoQueueTransport().enqueue( + queue="celery_executor_legacy", context=_ctx(), org_id="org9", + reply_key="rk1", + ) + kw = enq.call_args.kwargs + assert kw["task_name"] == "execute_extraction" + assert kw["queue"] == "celery_executor_legacy" + assert kw["args"] == [{"run_id": "r"}] + assert kw["org_id"] == "org9" + assert kw["reply_key"] == "rk1" + assert kw["task_id"] is None and kw["on_success"] is None + + def test_enqueue_carries_continuations(self): + spec = {"task_name": "ide_prompt_complete", "kwargs": {}, "queue": "ide_callback"} + with patch(f"{_MOD}.enqueue_task") as enq: + DjangoQueueTransport().enqueue( + queue="celery_executor_legacy", context=_ctx(), org_id="o", + on_success=spec, task_id="tid-7", + ) + kw = enq.call_args.kwargs + assert kw["on_success"] == spec and kw["task_id"] == "tid-7" + assert kw["reply_key"] is None # callback dispatch, not request-reply + + +class TestDjangoQueueTransportWait: + def test_present_row_folds_to_exec_result_row(self): + row = MagicMock(status="completed", result={"a": 1}, error="") + qs = MagicMock() + qs.filter.return_value.first.return_value = row + with patch(f"{_MOD}.PgTaskResult", MagicMock(objects=qs)): + out = DjangoQueueTransport().wait_for_result("rk", timeout=5) + assert isinstance(out, ExecResultRow) + assert out.status == "completed" and out.result == {"a": 1} + + def test_timeout_returns_none(self): + qs = MagicMock() + qs.filter.return_value.first.return_value = None # never appears + with ( + patch(f"{_MOD}.PgTaskResult", MagicMock(objects=qs)), + patch(f"{_MOD}.close_old_connections"), ): - assert resolve_executor_transport(_ctx()) is False - - -class TestRoutingZeroRegression: - @staticmethod - def _build(): - # Patch both sub-dispatchers at construction; the instances are captured - # in __init__ so they remain mocked after the context exits. + # timeout=0 → first poll misses, remaining <= 0 → None (no sleep). + assert DjangoQueueTransport().wait_for_result("rk", timeout=0) is None + + def test_multi_iteration_poll_misses_then_hits(self): + # The load-bearing backoff path: a miss, then a hit on the next poll — the + # connection is released (close_old_connections) and it sleeps once between. + row = MagicMock(status="completed", result={"a": 1}, error="") + qs = MagicMock() + qs.filter.return_value.first.side_effect = [None, row] with ( - patch(f"{_MOD}.ExecutionDispatcher") as celery_cls, - patch(f"{_MOD}.PgExecutionDispatcher") as pg_cls, + patch(f"{_MOD}.PgTaskResult", MagicMock(objects=qs)), + patch(f"{_MOD}.close_old_connections") as cl, + patch("unstract.core.polling.time.sleep") as slp, ): - dispatcher = RoutingExecutionDispatcher(celery_app="app") - return dispatcher, celery_cls.return_value, pg_cls.return_value + out = DjangoQueueTransport().wait_for_result("rk", timeout=5) + assert isinstance(out, ExecResultRow) and out.result == {"a": 1} + cl.assert_called_once() # between_polls released the conn on the miss + slp.assert_called_once() # slept once between the two polls - def test_gate_off_dispatch_uses_celery_only(self): - dispatcher, celery, pg = self._build() - with patch(f"{_MOD}.resolve_executor_transport", return_value=False): - dispatcher.dispatch(_ctx()) - celery.dispatch.assert_called_once() - pg.dispatch.assert_not_called() # the zero-regression guarantee - - def test_gate_on_dispatch_uses_pg(self): - dispatcher, celery, pg = self._build() - with patch(f"{_MOD}.resolve_executor_transport", return_value=True): - dispatcher.dispatch(_ctx()) - pg.dispatch.assert_called_once() - celery.dispatch.assert_not_called() - - def test_async_and_callback_stay_celery_when_gate_off(self): - """Zero-regression: gate off → async/callback delegate to Celery unchanged.""" - dispatcher, celery, pg = self._build() - with patch(f"{_MOD}.resolve_executor_transport", return_value=False): - dispatcher.dispatch_async(_ctx(), headers={"h": 1}) - dispatcher.dispatch_with_callback(_ctx(), on_success="s", on_error="e") - celery.dispatch_async.assert_called_once() - celery.dispatch_with_callback.assert_called_once() - pg.dispatch_async.assert_not_called() - pg.dispatch_with_callback.assert_not_called() - - def test_async_and_callback_route_to_pg_when_gated(self): - """Gate on (③c) → async/callback take the PG self-chained path.""" - dispatcher, celery, pg = self._build() - with patch(f"{_MOD}.resolve_executor_transport", return_value=True): - dispatcher.dispatch_async(_ctx()) - dispatcher.dispatch_with_callback( - _ctx(), on_success="s", on_error="e", task_id="t" - ) - pg.dispatch_async.assert_called_once() - pg.dispatch_with_callback.assert_called_once() - assert "headers" not in pg.dispatch_with_callback.call_args.kwargs - celery.dispatch_async.assert_not_called() - celery.dispatch_with_callback.assert_not_called() - - -class TestPgAsyncCallbackWiring: - """PG fire-and-forget + self-chained-callback enqueue shapes (``enqueue_task`` - mocked). Pins that the async path carries NO reply_key and the callback path - carries the translated continuations + the tracking task_id. - """ +class TestResolveExecutorTransport: @staticmethod - def _ctx(): - c = MagicMock() - c.executor_name = "legacy" - c.run_id = "r" - c.organization_id = "org9" - c.to_dict.return_value = {"run_id": "r", "organization_id": "org9"} - return c - - def test_dispatch_async_is_fire_and_forget(self): - with patch(f"{_MOD}.enqueue_task") as enq: - task_id = PgExecutionDispatcher().dispatch_async(self._ctx()) - kwargs = enq.call_args.kwargs - assert kwargs["task_name"] == "execute_extraction" - assert kwargs["queue"] == "celery_executor_legacy" - assert kwargs["org_id"] == "org9" - assert kwargs["task_id"] == task_id - assert "reply_key" not in kwargs - assert "on_success" not in kwargs - - def test_dispatch_with_callback_carries_continuations(self): - on_s = MagicMock( - task="ide_prompt_complete", - args=(), - kwargs={"callback_kwargs": {"room": "r1"}}, - options={"queue": "ide_callback"}, - ) - on_e = MagicMock( - task="ide_prompt_error", - args=(), - kwargs={"callback_kwargs": {"room": "r1"}}, - options={"queue": "ide_callback"}, - ) - with patch(f"{_MOD}.enqueue_task") as enq: - handle = PgExecutionDispatcher().dispatch_with_callback( - self._ctx(), on_success=on_s, on_error=on_e, task_id="tid-7" - ) - assert handle.id == "tid-7" # call sites read .id off the handle - kwargs = enq.call_args.kwargs - assert kwargs["on_success"] == { - "task_name": "ide_prompt_complete", - "kwargs": {"callback_kwargs": {"room": "r1"}}, - "queue": "ide_callback", - } - assert kwargs["on_error"]["task_name"] == "ide_prompt_error" - assert kwargs["task_id"] == "tid-7" - assert "reply_key" not in kwargs + def _gate(on: bool): + s = MagicMock() + s.PG_QUEUE_TRANSPORT_ENABLED = on + return patch(f"{_MOD}.settings", s) + + def test_reads_settings_master_gate_on(self): + with self._gate(True), patch( + f"{_MOD}.resolve_pg_transport", return_value=True + ) as r: + assert resolve_executor_transport(_ctx()) is True + assert r.call_args.kwargs["master_gate_enabled"] is True + + def test_reads_settings_master_gate_off(self): + with self._gate(False), patch( + f"{_MOD}.resolve_pg_transport", return_value=False + ) as r: + resolve_executor_transport(_ctx()) + assert r.call_args.kwargs["master_gate_enabled"] is False + + +class TestFactoryWiring: + def test_factory_wires_routing_with_django_transport(self): + d = get_executor_dispatcher(celery_app="app") + assert isinstance(d, RoutingExecutionDispatcher) + # PG dispatcher uses the backend ORM transport; gate = the settings resolver. + assert isinstance(d._pg._transport, DjangoQueueTransport) + assert d._resolve is resolve_executor_transport diff --git a/backend/uv.lock b/backend/uv.lock index 6997c3b82a..10a787b064 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -4037,6 +4037,7 @@ dependencies = [ { name = "unstract-core" }, { name = "unstract-filesystem" }, { name = "unstract-flags" }, + { name = "unstract-sdk1" }, { name = "unstract-tool-registry" }, { name = "unstract-tool-sandbox" }, ] @@ -4046,6 +4047,7 @@ requires-dist = [ { name = "unstract-core", editable = "../unstract/core" }, { name = "unstract-filesystem", editable = "../unstract/filesystem" }, { name = "unstract-flags", editable = "../unstract/flags" }, + { name = "unstract-sdk1", editable = "../unstract/sdk1" }, { name = "unstract-tool-registry", editable = "../unstract/tool-registry" }, { name = "unstract-tool-sandbox", editable = "../unstract/tool-sandbox" }, ] diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 397f001312..e00202c8f3 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -133,6 +133,12 @@ export default defineConfig(({ mode }) => { target: env.VITE_BACKEND_URL, changeOrigin: true, secure: false, + // Forward WebSocket upgrades too — the Socket.IO log/result + // channel connects to `/api/v1/socket` with a websocket-only + // transport. Without this the upgrade is never proxied to the + // backend and Prompt Studio results never stream to the UI in + // dev. (Prod is unaffected: Traefik routes /api/v1/socket.) + ws: true, }, } : undefined, diff --git a/unstract/core/src/unstract/core/execution_dispatch.py b/unstract/core/src/unstract/core/execution_dispatch.py index 57ddf02513..133f85a8dd 100644 --- a/unstract/core/src/unstract/core/execution_dispatch.py +++ b/unstract/core/src/unstract/core/execution_dispatch.py @@ -16,11 +16,27 @@ from __future__ import annotations -from typing import Any +from typing import Any, Protocol from .data_models import ContinuationSpec +class CallbackSignature(Protocol): + """Structural type of the Celery ``Signature`` the callback path accepts. + + Documents (and lets a type-checker enforce) the precondition + :func:`signature_to_continuation` reads: a task name, a ``queue`` in + ``options``, and kwargs-only (no positional ``args``). Defined here as a + Protocol — not imported from ``celery`` — so ``unstract.core`` stays + celery-free; a real ``celery.Signature`` conforms structurally. + """ + + task: str + options: dict[str, Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] + + class DispatchHandle: """Minimal duck-type of Celery ``AsyncResult`` for the PG callback path. @@ -36,7 +52,9 @@ def __init__(self, task_id: str) -> None: self.id = task_id -def signature_to_continuation(sig: Any | None) -> ContinuationSpec | None: +def signature_to_continuation( + sig: CallbackSignature | None, +) -> ContinuationSpec | None: """Translate a Celery ``Signature`` to a serialisable continuation spec. Reads only the three attributes PG self-chaining needs — task name, kwargs, diff --git a/unstract/core/src/unstract/core/polling.py b/unstract/core/src/unstract/core/polling.py new file mode 100644 index 0000000000..c48379f568 --- /dev/null +++ b/unstract/core/src/unstract/core/polling.py @@ -0,0 +1,48 @@ +"""Shared polling helper for the PG result backends. + +A capped-exponential-backoff poll loop (PgBouncer-safe; no LISTEN/NOTIFY) used by +*both* PG result pollers — the backend's ``DjangoQueueTransport.wait_for_result`` +(Django ORM) and the workers' ``PgResultBackend.wait_for_result`` (psycopg2). It +has no Django / psycopg / SDK dependency, so it lives in ``unstract.core`` where +both trees import it — the backoff lives in exactly one place to tune. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from collections.abc import Callable + +_T = TypeVar("_T") + + +def poll_for_row( + fetch: Callable[[], _T | None], + timeout: float, + *, + between_polls: Callable[[], None] | None = None, + initial: float = 0.2, + maximum: float = 2.0, +) -> _T | None: + """Poll ``fetch()`` until it returns non-``None`` or *timeout* elapses. + + Capped exponential backoff; the final sleep is clamped so we never overshoot the + deadline. ``between_polls`` runs once before each sleep — the backend poller + passes ``close_old_connections`` to release the DB connection between polls. + Returns the fetched row, or ``None`` on timeout. + """ + deadline = time.monotonic() + timeout + delay = initial + while True: + row = fetch() + if row is not None: + return row + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + if between_polls is not None: + between_polls() + time.sleep(min(delay, remaining)) + delay = min(delay * 2, maximum) diff --git a/unstract/workflow-execution/pyproject.toml b/unstract/workflow-execution/pyproject.toml index a49c7b45a1..3680e82f4d 100644 --- a/unstract/workflow-execution/pyproject.toml +++ b/unstract/workflow-execution/pyproject.toml @@ -9,6 +9,7 @@ readme = "README.md" # dependencies = [ "unstract-core", + "unstract-sdk1", "unstract-tool-registry", "unstract-tool-sandbox", "unstract-flags", @@ -21,6 +22,7 @@ unstract-tool-sandbox = { path = "../tool-sandbox", editable = true } unstract-tool-registry = { path = "../tool-registry", editable = true } unstract-flags = { path = "../flags", editable = true } unstract-core = { path = "../core", editable = true } +unstract-sdk1 = { path = "../sdk1", editable = true } # [build-system] # requires = ["hatchling"] diff --git a/unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py b/unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py new file mode 100644 index 0000000000..5d256a9132 --- /dev/null +++ b/unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py @@ -0,0 +1,467 @@ +"""Shared executor-RPC dispatch for the PG path — the gate + reply_key orchestration. + +The executor "RPC" is a synchronous request-reply: a caller sends an +``ExecutionContext`` to the executor worker and blocks for the ``ExecutionResult``. +The legacy transport is Celery (the SDK ``ExecutionDispatcher``); the PG path adds a +parallel Postgres transport. Backend (Django/prompt-studio) and workers +(``structure_tool``) both need it, and used to carry **byte-for-byte mirrors** of +this logic — the only thing that genuinely differs between them is the *transport +primitive*: the backend enqueues via the Django ORM (``enqueue_task`` + +``PgTaskResult``), the workers via psycopg2 (``PgQueueClient`` + ``PgResultBackend``). + +So this module owns everything transport-agnostic exactly once, and the differing +primitive is **injected** (composition, not inheritance) via :class:`QueueTransport`: + +- :class:`PgExecutionDispatcher` — concrete; ``dispatch`` / ``dispatch_async`` / + ``dispatch_with_callback`` + the reply_key/timeout orchestration and the + never-raises contract (timeout/failure → ``ExecutionResult.failure``). It calls + ``transport.enqueue(...)`` and ``transport.wait_for_result(...)``. +- :func:`resolve_pg_transport` — the gate: a master kill-switch (its boolean value + supplied by the caller — a Django setting on the backend, an env var on the + workers) then the single ``pg_queue_enabled`` Flipt flag, bucketed per org. Fails + closed to Celery. +- :class:`RoutingExecutionDispatcher` — picks PG-vs-Celery per call (instant + rollout/rollback) for every mode; the Celery dispatcher, the PG dispatcher and the + per-side ``resolve`` are all injected. + +It lives in ``unstract-workflow-execution`` (which both backend and workers already +depend on) rather than ``unstract.core`` because it needs ``unstract.sdk1`` and +``sdk1`` imports ``core`` — hosting it in ``core`` would be circular. It has no +Django/psycopg2 dependency: those live entirely in the injected transport. +""" + +from __future__ import annotations + +import logging +import os +import uuid +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Protocol + +from unstract.core.data_models import PgTaskStatus +from unstract.core.execution_dispatch import DispatchHandle, signature_to_continuation +from unstract.flags.feature_flag import check_feature_flag_status +from unstract.sdk1.execution.result import ExecutionResult + +if TYPE_CHECKING: + from collections.abc import Callable + + from unstract.core.data_models import ContinuationSpec + from unstract.core.execution_dispatch import CallbackSignature + from unstract.sdk1.execution.context import ExecutionContext + +logger = logging.getLogger(__name__) + +# The single PG-queue rollout flag — the same key execution and the scheduler read, +# so one flip turns the whole PG-queue feature on/off. +PG_QUEUE_FLAG_KEY = "pg_queue_enabled" +EXECUTE_TASK = "execute_extraction" +# Mirror the SDK's queue-per-executor convention so the PG executor queue name +# matches the Celery one (the worker-pg-executor consumer subscribes to these). +QUEUE_PREFIX = "celery_executor_" +# Caller-side wait default — mirrors the SDK dispatcher (EXECUTOR_RESULT_TIMEOUT env, +# else 3600s) so a PG-routed caller waits exactly as long as a Celery one. +DEFAULT_TIMEOUT_ENV = "EXECUTOR_RESULT_TIMEOUT" +DEFAULT_TIMEOUT = 3600 + + +class _CeleryDispatcher(Protocol): + """The subset of the SDK ``ExecutionDispatcher`` the router delegates to on the + Celery path — so ``RoutingExecutionDispatcher`` holds it typed, not as ``Any``. + """ + + def dispatch( + self, + context: ExecutionContext, + timeout: int | None = ..., + headers: dict[str, Any] | None = ..., + ) -> ExecutionResult: ... + + def dispatch_async( + self, context: ExecutionContext, headers: dict[str, Any] | None = ... + ) -> str: ... + + def dispatch_with_callback( + self, + context: ExecutionContext, + on_success: CallbackSignature | None = ..., + on_error: CallbackSignature | None = ..., + task_id: str | None = ..., + headers: dict[str, Any] | None = ..., + ) -> Any: ... + + +@dataclass +class ExecResultRow: + """Normalised executor-RPC result row — the transport-agnostic shape + :meth:`PgExecutionDispatcher.dispatch` interprets. + + The backend's result row is a Django model (attribute access) and the workers' + is a ``dict``; both fold to this so ``dispatch`` has one code path. Every field + defaults to ``None`` — the never-raises contract must not depend on the producer + having written every key. + """ + + status: str | None = None + result: dict | None = None + error: str | None = None + + +class QueueTransport(Protocol): + """The per-side primitive the shared dispatcher needs — the ONLY thing that + differs between backend and workers. + + ``enqueue`` writes one ``execute_extraction`` request-row (the optional keys + select the dispatch shape: ``reply_key`` → request-reply; ``on_success`` / + ``on_error`` / ``task_id`` → async/callback). ``wait_for_result`` polls for the + reply and returns a normalised :class:`ExecResultRow` (or ``None`` on timeout). + """ + + def enqueue( + self, + *, + queue: str, + context: ExecutionContext, + org_id: str, + reply_key: str | None = None, + on_success: ContinuationSpec | None = None, + on_error: ContinuationSpec | None = None, + task_id: str | None = None, + ) -> None: ... + + def wait_for_result(self, reply_key: str, timeout: float) -> ExecResultRow | None: ... + + +def resolve_pg_transport( + context: ExecutionContext, + *, + master_gate_enabled: bool, + flag_key: str = PG_QUEUE_FLAG_KEY, +) -> bool: + """True → route this executor dispatch over PG; False → Celery (default). + + Master-gated by ``master_gate_enabled`` (the caller supplies its value — a Django + setting on the backend, an env var on the workers), then the single + ``pg_queue_enabled`` Flipt flag, bucketed per org. **Fails closed to Celery** on a + closed gate, a blind Flipt, or any error — so the executor never silently loses + its transport. + """ + if not master_gate_enabled: + return False + if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": + logger.warning( + "resolve_pg_transport: gate ON but FLIPT_SERVICE_AVAILABLE != true " + "(Flipt blind); using Celery" + ) + return False + org = getattr(context, "organization_id", None) + # %-bucket keyed on org; fall back to run_id so a context without an org still + # resolves deterministically. + entity_id = str(org or getattr(context, "run_id", "") or "default") + flag_context = {"executor_name": str(context.executor_name)} + if org: + flag_context["organization_id"] = str(org) + try: + enabled = check_feature_flag_status( + flag_key=flag_key, entity_id=entity_id, context=flag_context + ) + except Exception: + logger.warning( + "resolve_pg_transport: Flipt check failed; using Celery", exc_info=True + ) + return False + return bool(enabled) + + +def _resolve_timeout(timeout: int | None) -> int: + """Caller timeout, defaulting to ``EXECUTOR_RESULT_TIMEOUT`` env then 3600s. + + Guarded so a misconfigured env value can't raise out of ``dispatch`` (the + never-raises contract) — it logs and falls back instead of silently waiting the + full default with no signal. + """ + if timeout is not None: + return timeout + try: + return int(os.environ.get(DEFAULT_TIMEOUT_ENV, DEFAULT_TIMEOUT)) + except (TypeError, ValueError): + logger.warning( + "PG executor dispatch: invalid %s=%r; falling back to %ss", + DEFAULT_TIMEOUT_ENV, + os.environ.get(DEFAULT_TIMEOUT_ENV), + DEFAULT_TIMEOUT, + ) + return DEFAULT_TIMEOUT + + +class PgExecutionDispatcher: + """PG request-reply executor dispatch — drop-in for the SDK dispatch contract. + + Concrete + transport-injected: enqueues ``execute_extraction`` with a unique + ``reply_key`` and blocks on the result row until the executor consumer records it + or the timeout elapses. Honours the SDK dispatcher contract: it never raises and + converts a timeout/failure into ``ExecutionResult.failure`` so callers branch on + ``result.success`` identically on either transport. + """ + + def __init__(self, transport: QueueTransport) -> None: + self._transport = transport + + def dispatch( + self, + context: ExecutionContext, + timeout: int | None = None, + ) -> ExecutionResult: + """Send ``execute_extraction`` and block for the result (request-reply). + + Enqueues with a unique ``reply_key``, polls the result row until it appears + or *timeout* elapses, and converts the outcome to an ``ExecutionResult``. + Never raises (the SDK dispatch contract): an enqueue/poll failure, a timeout, + or a malformed/failed/empty result all become ``ExecutionResult.failure`` so + callers branch on ``result.success`` identically on either transport. + + No ``headers`` on any PG dispatch method: the PG path carries org/routing in + the enqueue payload, not Celery headers, so the ``RoutingExecutionDispatcher`` + does not forward fairness headers to the PG path. + """ + timeout = _resolve_timeout(timeout) + reply_key = str(uuid.uuid4()) + queue = f"{QUEUE_PREFIX}{context.executor_name}" + org = str(getattr(context, "organization_id", "") or "") + try: + self._transport.enqueue( + queue=queue, context=context, org_id=org, reply_key=reply_key + ) + except Exception as exc: + logger.exception( + "PG executor dispatch: enqueue failed (executor=%s run_id=%s)", + context.executor_name, + context.run_id, + ) + return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}") + logger.info( + "PG executor dispatch: enqueued reply_key=%s queue=%s run_id=%s " + "timeout=%ss; waiting for result...", + reply_key, + queue, + context.run_id, + timeout, + ) + try: + row = self._transport.wait_for_result(reply_key, timeout) + except Exception as exc: + # Honour the never-raises contract even if the poll connection dies. + logger.exception( + "PG executor dispatch: wait failed (reply_key=%s run_id=%s)", + reply_key, + context.run_id, + ) + return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}") + if row is None: + # On timeout the executor task may still be running on the consumer; it + # writes its outcome under this reply_key, but we've given up reading it + # (the reaper retention-sweeps the orphan row). A retry re-dispatches with + # a FRESH reply_key — at-least-once + caller-timeout by design. + logger.warning( + "PG executor dispatch: TIMEOUT after %ss (reply_key=%s run_id=%s) — " + "the executor task may still be running", + timeout, + reply_key, + context.run_id, + ) + return ExecutionResult.failure( + error=f"TimeoutError: executor reply not received within {timeout}s" + ) + if row.status == PgTaskStatus.COMPLETED.value and row.result is not None: + try: + return ExecutionResult.from_dict(row.result) + except Exception as exc: + # A malformed completed row becomes a failure result, not a raise. + # Surface the parse cause so a UI reading result.error isn't left with + # an opaque message. + logger.exception( + "PG executor dispatch: malformed completed result " + "(reply_key=%s run_id=%s)", + reply_key, + context.run_id, + ) + return ExecutionResult.failure( + error=( + f"Malformed executor result ({type(exc).__name__}) " + f"for reply_key {reply_key}" + ) + ) + if row.status == PgTaskStatus.COMPLETED.value: + # COMPLETED but result is None — a producer-side anomaly (the consumer + # recorded success yet wrote no payload). Distinguish it from a real task + # failure so it isn't mislabelled "executor task failed". + logger.warning( + "PG executor dispatch: completed row has no result " + "(reply_key=%s run_id=%s)", + reply_key, + context.run_id, + ) + return ExecutionResult.failure( + error=f"Executor reported completion with no result (reply_key {reply_key})" + ) + logger.warning( + "PG executor dispatch: executor reported failure (reply_key=%s " + "run_id=%s): %s", + reply_key, + context.run_id, + row.error or "(no error)", + ) + return ExecutionResult.failure(error=row.error or "executor task failed") + + def dispatch_async(self, context: ExecutionContext) -> str: + """Fire-and-forget enqueue of ``execute_extraction``; returns the task id. + + No ``reply_key``, no callback, no blocking. A caller that needs the outcome + uses :meth:`dispatch_with_callback` (a self-chained continuation), not polling + on this id. Enqueue failures **propagate** — parity with the SDK, which lets a + broker error out of ``dispatch_async`` — but are logged here first so the + failure is observable even if the caller swallows it. + """ + task_id = str(uuid.uuid4()) + queue = f"{QUEUE_PREFIX}{context.executor_name}" + org = str(getattr(context, "organization_id", "") or "") + try: + self._transport.enqueue( + queue=queue, context=context, org_id=org, task_id=task_id + ) + except Exception: + # The enqueue is the only fallible step (fire-and-forget). Log before the + # re-raise so a swallowed error is still observable. + logger.exception( + "PG executor dispatch_async: enqueue failed (executor=%s run_id=%s)", + context.executor_name, + context.run_id, + ) + raise + logger.info( + "PG executor dispatch_async: enqueued task_id=%s queue=%s run_id=%s", + task_id, + queue, + context.run_id, + ) + return task_id + + def dispatch_with_callback( + self, + context: ExecutionContext, + on_success: CallbackSignature | None = None, + on_error: CallbackSignature | None = None, + task_id: str | None = None, + ) -> DispatchHandle: + """Fire-and-forget enqueue with self-chained callbacks (§5 model). + + Instead of Celery ``link`` / ``link_error``, the on-success / on-error Celery + ``Signature``s are translated to serialisable ``ContinuationSpec``s and + carried in the payload; after the executor runs, the consumer self-chains the + matching continuation. Returns a :class:`DispatchHandle` exposing ``.id`` + (== ``task_id``) so call sites read the task id exactly as on the Celery path. + + Enqueue failures **propagate** (parity with :meth:`dispatch_async`), logged + first. NOTE: because the continuations are carried *in the payload*, a failed + enqueue means the executor never runs and ``on_error`` never fires — so a + caller MUST treat a raised enqueue as the failure signal itself (the + prompt-studio views do, in their own try/except). + """ + task_id = task_id or str(uuid.uuid4()) + queue = f"{QUEUE_PREFIX}{context.executor_name}" + org = str(getattr(context, "organization_id", "") or "") + success_spec = signature_to_continuation(on_success) + error_spec = signature_to_continuation(on_error) + try: + self._transport.enqueue( + queue=queue, + context=context, + org_id=org, + on_success=success_spec, + on_error=error_spec, + task_id=task_id, + ) + except Exception: + logger.exception( + "PG executor dispatch_with_callback: enqueue failed — on_error will " + "NOT fire (executor=%s run_id=%s)", + context.executor_name, + context.run_id, + ) + raise + logger.info( + "PG executor dispatch_with_callback: enqueued task_id=%s queue=%s " + "run_id=%s on_success=%s on_error=%s", + task_id, + queue, + context.run_id, + success_spec["task_name"] if success_spec else None, + error_spec["task_name"] if error_spec else None, + ) + return DispatchHandle(task_id) + + +class RoutingExecutionDispatcher: + """Gate-routed executor dispatcher: every mode picks PG vs Celery per call + (read at dispatch time → flipping the flag is an instant, no-redeploy + rollout/rollback). Duck-typed against the SDK ``ExecutionDispatcher`` so call + sites are unchanged. + + Composition-injected: the Celery dispatcher (``celery``), the PG dispatcher + (``pg``) and the per-side gate (``resolve(context) -> bool``). + """ + + def __init__( + self, + *, + celery: _CeleryDispatcher, + pg: PgExecutionDispatcher, + resolve: Callable[[ExecutionContext], bool], + ) -> None: + self._celery = celery + self._pg = pg + self._resolve = resolve + + def dispatch( + self, + context: ExecutionContext, + timeout: int | None = None, + headers: dict[str, Any] | None = None, + ) -> ExecutionResult: + if self._resolve(context): + logger.info( + "Executor RPC → PG transport (executor=%s run_id=%s)", + context.executor_name, + context.run_id, + ) + # PG carries org/routing via the enqueue payload, not Celery headers, so + # the fairness headers are intentionally not forwarded here. + return self._pg.dispatch(context, timeout=timeout) + return self._celery.dispatch(context, timeout=timeout, headers=headers) + + def dispatch_async( + self, context: ExecutionContext, headers: dict[str, Any] | None = None + ) -> str: + if self._resolve(context): + return self._pg.dispatch_async(context) + return self._celery.dispatch_async(context, headers=headers) + + def dispatch_with_callback( + self, + context: ExecutionContext, + on_success: CallbackSignature | None = None, + on_error: CallbackSignature | None = None, + task_id: str | None = None, + headers: dict[str, Any] | None = None, + ) -> Any: + if self._resolve(context): + return self._pg.dispatch_with_callback( + context, on_success=on_success, on_error=on_error, task_id=task_id + ) + return self._celery.dispatch_with_callback( + context, + on_success=on_success, + on_error=on_error, + task_id=task_id, + headers=headers, + ) diff --git a/workers/queue_backend/pg_queue/executor_rpc.py b/workers/queue_backend/pg_queue/executor_rpc.py index a43754b84b..1b3367bb03 100644 --- a/workers/queue_backend/pg_queue/executor_rpc.py +++ b/workers/queue_backend/pg_queue/executor_rpc.py @@ -1,328 +1,104 @@ -"""Executor-RPC transport routing for the PG path — workers side (Phase 9, ③b-2). - -The workers-side twin of ``backend/pg_queue/executor_rpc.py``. The executor "RPC" -is a synchronous request-reply: a caller (here, the ``structure_tool`` task in the -file_processing worker) sends an ``ExecutionContext`` to the executor worker and -blocks for the ``ExecutionResult``. The legacy transport is Celery — the SDK -``ExecutionDispatcher`` (``send_task`` + ``AsyncResult.get``). This module adds a -**parallel** Postgres transport that leaves Celery and the SDK completely -untouched (no SDK edit, no change to the ``execute_extraction`` task or the Celery -executor worker): - -- :class:`PgExecutionDispatcher` enqueues ``execute_extraction`` onto the PG queue - (via :class:`~queue_backend.pg_queue.client.PgQueueClient`) with a unique - ``reply_key`` and polls ``pg_task_result`` (via - :class:`~queue_backend.pg_queue.result_backend.PgResultBackend`) for the reply — - same ``.dispatch()`` contract as the SDK dispatcher (never raises; - failure/timeout → ``ExecutionResult.failure``). The already-running - ``worker-pg-executor`` consumer runs the task and writes the reply, so this side - only adds the enqueue + poll halves. -- :func:`resolve_executor_transport` is the gate: master - ``PG_QUEUE_TRANSPORT_ENABLED`` (env, the workers analogue of the backend's - Django setting) then the **single** Flipt flag ``pg_queue_enabled`` — the same - flag the execution path uses, so one flip turns the whole PG-queue feature - on/off. Fails closed to Celery. -- :class:`RoutingExecutionDispatcher` is what ``structure_tool`` gets from - :func:`get_executor_dispatcher`: ``dispatch()`` picks PG-vs-Celery **per call** - (read at dispatch time → flipping the flag is an instant, no-redeploy - rollout/rollback); ``dispatch_async`` / ``dispatch_with_callback`` always - delegate to Celery (the callback path is a later slice). - -Zero-regression: gate off ⇒ every method delegates to the unchanged Celery -``ExecutionDispatcher`` and no ``pg_task_result`` row is created. - -TODO(shared): ``resolve_executor_transport`` and the reply_key/poll orchestration -mirror the backend module almost verbatim — only the transport primitives differ -(psycopg2 here vs Django ORM there). A later slice can lift the shared logic into -``unstract.core`` so the gate/contract lives in one place. +"""Executor-RPC for the PG path — workers (psycopg2) transport adapter. + +The gate + reply_key/timeout orchestration + routing live ONCE in +``unstract.workflow_execution.executor_rpc`` (shared with the backend). This module +is the thin psycopg2 half: a :class:`PgClientQueueTransport` that enqueues via +:class:`~queue_backend.pg_queue.client.PgQueueClient` and polls via +:class:`~queue_backend.pg_queue.result_backend.PgResultBackend`, plus the per-side +gate (master switch = the ``PG_QUEUE_TRANSPORT_ENABLED`` env, the workers analogue of +the backend's Django setting) and the :func:`get_executor_dispatcher` factory. + +Zero-regression: gate off ⇒ the routing dispatcher delegates every mode to the +unchanged Celery ``ExecutionDispatcher`` and no ``pg_task_result`` row is created. """ from __future__ import annotations import logging import os -import uuid -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from unstract.core.data_models import ContinuationSpec, PgTaskStatus -from unstract.core.execution_dispatch import DispatchHandle, signature_to_continuation -from unstract.flags.feature_flag import check_feature_flag_status from unstract.sdk1.execution.dispatcher import ExecutionDispatcher -from unstract.sdk1.execution.result import ExecutionResult +from unstract.workflow_execution.executor_rpc import ( + EXECUTE_TASK, + ExecResultRow, + PgExecutionDispatcher, + QueueTransport, + RoutingExecutionDispatcher, + resolve_pg_transport, +) from .client import PgQueueClient from .result_backend import PgResultBackend from .task_payload import to_payload if TYPE_CHECKING: + from unstract.core.data_models import ContinuationSpec from unstract.sdk1.execution.context import ExecutionContext +# Re-exported so existing ``from queue_backend.pg_queue.executor_rpc import …`` +# imports keep working. +__all__ = [ + "PgClientQueueTransport", + "PgExecutionDispatcher", + "RoutingExecutionDispatcher", + "get_executor_dispatcher", + "resolve_executor_transport", +] + logger = logging.getLogger(__name__) -# The single PG-queue rollout flag — same key the execution path and scheduler -# read. Defined in backend/pg_queue/flags.py too; kept as a literal here because -# workers can't import backend code (see TODO(shared) in the module docstring). -PG_QUEUE_FLAG_KEY = "pg_queue_enabled" -# Master kill-switch + deploy-ordering gate. The workers analogue of the backend's -# ``settings.PG_QUEUE_TRANSPORT_ENABLED`` — read straight from the env here. +# Master kill-switch + deploy-ordering gate — the workers analogue of the backend's +# ``settings.PG_QUEUE_TRANSPORT_ENABLED``, read straight from the env here. _MASTER_GATE_ENV = "PG_QUEUE_TRANSPORT_ENABLED" - -_EXECUTE_TASK = "execute_extraction" -# Mirror the SDK's queue-per-executor convention so the PG executor queue name -# matches the Celery one (the worker-pg-executor consumer subscribes to these). -_QUEUE_PREFIX = "celery_executor_" -# Caller-side wait default — mirrors the SDK dispatcher (EXECUTOR_RESULT_TIMEOUT -# env, else 3600s) so a PG-routed caller waits exactly as long as a Celery one. -_DEFAULT_TIMEOUT_ENV = "EXECUTOR_RESULT_TIMEOUT" -_DEFAULT_TIMEOUT = 3600 +_TRUE = "true" +_FALSE = "false" def resolve_executor_transport(context: ExecutionContext) -> bool: """True → route this executor dispatch over PG; False → Celery (default). - Mirrors the backend ``resolve_executor_transport``: master-gated by the - ``PG_QUEUE_TRANSPORT_ENABLED`` env, then the **single** ``pg_queue_enabled`` - Flipt flag (shared across the whole PG-queue feature), bucketed per org. - **Fails closed to Celery** on a closed gate, a blind Flipt, or any error — so - the executor never silently loses its transport. + The workers gate: master switch = the ``PG_QUEUE_TRANSPORT_ENABLED`` env, then the + shared Flipt eval (single ``pg_queue_enabled`` flag, fail-closed). """ - if os.environ.get(_MASTER_GATE_ENV, "false").lower() != "true": - return False - if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": + raw = os.environ.get(_MASTER_GATE_ENV, _FALSE) + master = raw.strip().lower() == _TRUE + if not master and raw.strip().lower() != _FALSE: + # A fat-fingered value ("1"/"yes"/"on"/" True ") parses to OFF — warn so it + # isn't a silent no-op for an operator who expected it to enable PG. logger.warning( - "resolve_executor_transport: gate ON but FLIPT_SERVICE_AVAILABLE != " - "true (Flipt blind); using Celery" - ) - return False - org = getattr(context, "organization_id", None) - # %-bucket keyed on org; fall back to run_id so a context without an org still - # resolves deterministically (mirrors the backend resolver). - entity_id = str(org or getattr(context, "run_id", "") or "default") - flag_context = {"executor_name": str(context.executor_name)} - if org: - flag_context["organization_id"] = str(org) - try: - enabled = check_feature_flag_status( - flag_key=PG_QUEUE_FLAG_KEY, entity_id=entity_id, context=flag_context + "resolve_executor_transport: %s=%r is not 'true'/'false' — treating as " + "OFF (PG transport disabled); use exactly 'true' to enable", + _MASTER_GATE_ENV, + raw, ) - except Exception: - logger.warning( - "resolve_executor_transport: Flipt check failed; using Celery", - exc_info=True, - ) - return False - return bool(enabled) + return resolve_pg_transport(context, master_gate_enabled=master) -class PgExecutionDispatcher: - """PG request-reply executor dispatch — drop-in for ``ExecutionDispatcher.dispatch``. +class PgClientQueueTransport(QueueTransport): + """:class:`QueueTransport` over psycopg2 (the workers half). - Enqueues ``execute_extraction`` with a unique ``reply_key`` and blocks on - ``pg_task_result`` until the executor consumer records the result or the - timeout elapses. Honours the same contract as the SDK dispatcher: it never - raises and converts a timeout/failure into ``ExecutionResult.failure`` so - callers can branch on ``result.success`` identically on either transport. + Inherits the Protocol so a type-checker verifies this implementation against the + seam independently of the ``PgExecutionDispatcher(...)`` construction site. """ - def dispatch( + def enqueue( self, - context: ExecutionContext, - timeout: int | None = None, - headers: dict[str, Any] | None = None, - ) -> ExecutionResult: - # ``headers`` is accepted (and ignored) for substitutability with the SDK - # ``ExecutionDispatcher.dispatch`` / ``RoutingExecutionDispatcher.dispatch`` - # shapes — the PG path carries org/routing via the enqueue payload, not - # Celery headers, so fairness headers are intentionally not forwarded. - if timeout is None: - # Guard the env parse so a misconfigured EXECUTOR_RESULT_TIMEOUT can't - # raise out of dispatch() (the never-raises contract). - try: - timeout = int(os.environ.get(_DEFAULT_TIMEOUT_ENV, _DEFAULT_TIMEOUT)) - except (TypeError, ValueError): - # Don't swallow silently — an operator who fat-fingers the value - # would otherwise wait the 3600s default with no signal. - logger.warning( - "PG executor dispatch: invalid %s=%r; falling back to %ss", - _DEFAULT_TIMEOUT_ENV, - os.environ.get(_DEFAULT_TIMEOUT_ENV), - _DEFAULT_TIMEOUT, - ) - timeout = _DEFAULT_TIMEOUT - reply_key = str(uuid.uuid4()) - queue = f"{_QUEUE_PREFIX}{context.executor_name}" - org = str(getattr(context, "organization_id", "") or "") - try: - self._enqueue(queue, context, org, reply_key=reply_key) - except Exception as exc: - logger.exception( - "PG executor dispatch: enqueue failed (executor=%s run_id=%s)", - context.executor_name, - context.run_id, - ) - return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}") - logger.info( - "PG executor dispatch: enqueued reply_key=%s queue=%s run_id=%s " - "timeout=%ss; waiting for result...", - reply_key, - queue, - context.run_id, - timeout, - ) - try: - row = self._wait_for_result(reply_key, timeout) - except Exception as exc: - # Honour the never-raises contract even if the poll connection dies. - logger.exception( - "PG executor dispatch: wait failed (reply_key=%s run_id=%s)", - reply_key, - context.run_id, - ) - return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}") - if row is None: - # On timeout the executor task may still be running on the consumer; - # it will write its outcome under this reply_key, but we've already - # given up reading it (the reaper retention-sweeps the orphan row). If - # the workflow engine retries the file execution, it re-dispatches with - # a FRESH reply_key — so two executor tasks for the same file can - # overlap (double LLM spend / duplicate writes). De-duping that belongs - # at the file-execution layer, not here; this transport stays at-least- - # once + caller-timeout by design. - logger.warning( - "PG executor dispatch: TIMEOUT after %ss (reply_key=%s run_id=%s) — " - "the executor task may still be running", - timeout, - reply_key, - context.run_id, - ) - return ExecutionResult.failure( - error=f"TimeoutError: executor reply not received within {timeout}s" - ) - # ``.get`` (not ``[...]``) so a result row missing ``status`` can't raise - # out of dispatch() — the never-raises contract must not depend on the - # producer always writing every key. - if ( - row.get("status") == PgTaskStatus.COMPLETED.value - and row.get("result") is not None - ): - try: - return ExecutionResult.from_dict(row["result"]) - except Exception as exc: - # A malformed completed row becomes a failure result, not a raise. - # Surface the parse cause (like the enqueue/wait paths) so a UI - # reading result.error isn't left with an opaque message. - logger.exception( - "PG executor dispatch: malformed completed result " - "(reply_key=%s run_id=%s)", - reply_key, - context.run_id, - ) - return ExecutionResult.failure( - error=( - f"Malformed executor result ({type(exc).__name__}) " - f"for reply_key {reply_key}" - ) - ) - logger.warning( - "PG executor dispatch: executor reported failure (reply_key=%s " - "run_id=%s): %s", - reply_key, - context.run_id, - row.get("error") or "(no error)", - ) - return ExecutionResult.failure(error=row.get("error") or "executor task failed") - - def dispatch_async( - self, context: ExecutionContext, headers: dict[str, Any] | None = None - ) -> str: - """Fire-and-forget enqueue of ``execute_extraction``; returns the task id. - - The PG analogue of the SDK ``dispatch_async``: no ``reply_key``, no - callback, no blocking. There is no PG ``AsyncResult`` backend, so a caller - that needs the outcome uses :meth:`dispatch_with_callback` (a self-chained - continuation), not polling on this id. ``headers`` is accepted and ignored - for substitutability (PG carries routing in the payload, not Celery - headers). Enqueue failures propagate — parity with the SDK, which lets a - broker error out of ``dispatch_async``. - """ - task_id = str(uuid.uuid4()) - queue = f"{_QUEUE_PREFIX}{context.executor_name}" - org = str(getattr(context, "organization_id", "") or "") - self._enqueue(queue, context, org, task_id=task_id) - logger.info( - "PG executor dispatch_async: enqueued task_id=%s queue=%s run_id=%s", - task_id, - queue, - context.run_id, - ) - return task_id - - def dispatch_with_callback( - self, - context: ExecutionContext, - on_success: Any | None = None, - on_error: Any | None = None, - task_id: str | None = None, - headers: dict[str, Any] | None = None, - ) -> DispatchHandle: - """Fire-and-forget enqueue with self-chained callbacks (§5 model). - - The PG analogue of the SDK ``dispatch_with_callback``: instead of Celery - ``link`` / ``link_error`` (which the broker fires), the on-success / - on-error Celery ``Signature``s are translated to serialisable - :class:`ContinuationSpec`s and carried in the payload. After the executor - consumer runs ``execute_extraction`` it self-chains the matching - continuation onto the callback queue. Returns a :class:`DispatchHandle` - exposing ``.id`` (== ``task_id``) so call sites read the task id exactly - as on the Celery path. ``headers`` is accepted and ignored (see - :meth:`dispatch_async`). - """ - task_id = task_id or str(uuid.uuid4()) - queue = f"{_QUEUE_PREFIX}{context.executor_name}" - org = str(getattr(context, "organization_id", "") or "") - success_spec = signature_to_continuation(on_success) - error_spec = signature_to_continuation(on_error) - self._enqueue( - queue, - context, - org, - on_success=success_spec, - on_error=error_spec, - task_id=task_id, - ) - logger.info( - "PG executor dispatch_with_callback: enqueued task_id=%s queue=%s " - "run_id=%s on_success=%s on_error=%s", - task_id, - queue, - context.run_id, - success_spec["task_name"] if success_spec else None, - error_spec["task_name"] if error_spec else None, - ) - return DispatchHandle(task_id) - - @staticmethod - def _enqueue( + *, queue: str, context: ExecutionContext, org_id: str, - *, reply_key: str | None = None, on_success: ContinuationSpec | None = None, on_error: ContinuationSpec | None = None, task_id: str | None = None, ) -> None: - """Enqueue an ``execute_extraction`` message (request-reply or callback). - - A short-lived client owns its connection for just the insert (which - commits internally) so the message is durably visible to the - ``worker-pg-executor`` consumer before we begin polling — and no - connection is pinned for the whole (possibly long) RPC. The optional keys - select the dispatch shape: ``reply_key`` → request-reply; ``on_success`` / - ``on_error`` / ``task_id`` → async/callback (self-chained). - """ + # A short-lived client owns its connection for just the insert (which commits + # internally) so the message is durably visible to the worker-pg-executor + # consumer before we begin polling — and no connection is pinned for the whole + # (possibly long) RPC. payload = to_payload( - _EXECUTE_TASK, + EXECUTE_TASK, args=[context.to_dict()], queue=queue, reply_key=reply_key, @@ -333,83 +109,19 @@ def _enqueue( with PgQueueClient() as client: client.send(queue, payload, org_id=org_id) - @staticmethod - def _wait_for_result(reply_key: str, timeout: float) -> dict[str, Any] | None: + def wait_for_result(self, reply_key: str, timeout: float) -> ExecResultRow | None: """Poll ``pg_task_result`` until the row appears or *timeout* elapses. - Poll-based with capped backoff (PgBouncer-safe; no LISTEN/NOTIFY). The - backend owns one connection for the duration of the wait and closes it on - exit, so a long RPC never leaks a connection. The pin is bounded: a - file_processing worker runs ``--pool=prefork`` with - ``WORKER_FILE_PROCESSING_CONCURRENCY`` (default 4) processes, and each - dispatches sequentially, so at most ~concurrency connections are held for - up to ``EXECUTOR_RESULT_TIMEOUT``. (The backend twin instead releases via - ``close_old_connections`` between polls; if file_processing concurrency is - raised materially, do the same here.) + ``PgResultBackend`` owns one connection for the duration of the wait and + closes it on exit, so a long RPC never leaks a connection. The result row is a + ``{status, result, error}`` dict; fold it to the shared :class:`ExecResultRow`. """ with PgResultBackend() as rb: - return rb.wait_for_result(reply_key, timeout) - - -class RoutingExecutionDispatcher: - """Gate-routed executor dispatcher returned by :func:`get_executor_dispatcher`. - - Every mode chooses PG vs Celery per call (instant rollout/rollback): - ``dispatch()`` (request-reply), ``dispatch_async`` (fire-and-forget) and - ``dispatch_with_callback`` (self-chained callbacks). Duck-typed against the SDK - ``ExecutionDispatcher`` so call sites are unchanged. - """ - - def __init__(self, celery_app: object | None = None) -> None: - self._celery = ExecutionDispatcher(celery_app=celery_app) - self._pg = PgExecutionDispatcher() - - def dispatch( - self, - context: ExecutionContext, - timeout: int | None = None, - headers: dict[str, Any] | None = None, - ) -> ExecutionResult: - if resolve_executor_transport(context): - logger.info( - "Executor RPC → PG transport (executor=%s run_id=%s)", - context.executor_name, - context.run_id, - ) - # PG carries org/routing via the enqueue payload, not Celery headers, - # so the fairness headers are intentionally not forwarded here (parity - # with the backend executor RPC). - return self._pg.dispatch(context, timeout=timeout) - return self._celery.dispatch(context, timeout=timeout, headers=headers) - - def dispatch_async( - self, context: ExecutionContext, headers: dict[str, Any] | None = None - ) -> str: - if resolve_executor_transport(context): - return self._pg.dispatch_async(context) - return self._celery.dispatch_async(context, headers=headers) - - def dispatch_with_callback( - self, - context: ExecutionContext, - on_success: Any | None = None, - on_error: Any | None = None, - task_id: str | None = None, - headers: dict[str, Any] | None = None, - ) -> Any: - if resolve_executor_transport(context): - return self._pg.dispatch_with_callback( - context, - on_success=on_success, - on_error=on_error, - task_id=task_id, - ) - return self._celery.dispatch_with_callback( - context, - on_success=on_success, - on_error=on_error, - task_id=task_id, - headers=headers, + row = rb.wait_for_result(reply_key, timeout) + if row is None: + return None + return ExecResultRow( + status=row.get("status"), result=row.get("result"), error=row.get("error") ) @@ -417,4 +129,8 @@ def get_executor_dispatcher( celery_app: object | None = None, ) -> RoutingExecutionDispatcher: """Factory: the gate-routed executor dispatcher (PG when enabled, else Celery).""" - return RoutingExecutionDispatcher(celery_app=celery_app) + return RoutingExecutionDispatcher( + celery=ExecutionDispatcher(celery_app=celery_app), + pg=PgExecutionDispatcher(PgClientQueueTransport()), + resolve=resolve_executor_transport, + ) diff --git a/workers/queue_backend/pg_queue/result_backend.py b/workers/queue_backend/pg_queue/result_backend.py index 208d184913..e6544afc49 100644 --- a/workers/queue_backend/pg_queue/result_backend.py +++ b/workers/queue_backend/pg_queue/result_backend.py @@ -32,13 +32,13 @@ import contextlib import json import logging -import time from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Self import psycopg2 from unstract.core.data_models import PgTaskStatus +from unstract.core.polling import poll_for_row from .connection import create_pg_connection @@ -164,22 +164,18 @@ def wait_for_result( ) -> dict[str, Any] | None: """Block until the result row appears or *timeout* seconds elapse. - Returns the ``{status, result, error}`` dict, or ``None`` on timeout. - Poll-based with exponential backoff (capped) — PgBouncer-safe, no - persistent listener. The final sleep is clamped so we never overshoot - the deadline. + Returns the ``{status, result, error}`` dict, or ``None`` on timeout. Uses + the shared :func:`~unstract.core.polling.poll_for_row` backoff (capped + exponential, PgBouncer-safe; the final sleep is clamped to the deadline) — the + same skeleton the backend's ``DjangoQueueTransport`` poller uses, so the + backoff lives in one place. """ - deadline = time.monotonic() + timeout - delay = poll_interval - while True: - row = self.get_result(task_id) - if row is not None: - return row - remaining = deadline - time.monotonic() - if remaining <= 0: - return None - time.sleep(min(delay, remaining)) - delay = min(delay * 2, _POLL_MAX_SECONDS) + return poll_for_row( + lambda: self.get_result(task_id), + timeout, + initial=poll_interval, + maximum=_POLL_MAX_SECONDS, + ) def close(self) -> None: """Close an owned connection (injected connections are the caller's).""" diff --git a/workers/tests/test_executor_rpc.py b/workers/tests/test_executor_rpc.py index 823804e2a9..75ed262a58 100644 --- a/workers/tests/test_executor_rpc.py +++ b/workers/tests/test_executor_rpc.py @@ -1,423 +1,365 @@ -"""Tests for the workers-side executor-RPC transport routing (Phase 9, ③b-2). - -DB-free: the env gate / Flipt / the enqueue + poll halves are mocked. Pins the -gate's fail-closed matrix and — the load-bearing zero-regression property — that -with the gate off ``RoutingExecutionDispatcher`` delegates EVERY mode to the -unchanged Celery ``ExecutionDispatcher`` and never touches the PG path. Mirrors -``backend/pg_queue/tests/test_executor_rpc.py`` (the backend twin) adapted to the -worker primitives: an env master-gate instead of a Django setting, and the result -row is a plain ``dict`` (``PgResultBackend``) instead of a Django model. +"""Tests for the executor-RPC dispatch (UN-3607 shared module + the workers adapter). + +The gate + reply_key/timeout orchestration + routing now live ONCE in +``unstract.workflow_execution.executor_rpc`` (shared by backend + workers). This +suite is the home for the **shared contract** (tested against a fake transport, so +the dispatch never-raises / result-interpretation / routing logic is verified a +single time, not per-mirror) PLUS the **workers adapter** (``PgClientQueueTransport`` ++ the env-master-gate ``resolve_executor_transport`` + the ``get_executor_dispatcher`` +wiring). The backend adapter is tested in ``backend/pg_queue/tests/test_executor_rpc.py``. """ from unittest.mock import MagicMock, patch import pytest from queue_backend.pg_queue.executor_rpc import ( - PgExecutionDispatcher, + PgClientQueueTransport, RoutingExecutionDispatcher, + get_executor_dispatcher, resolve_executor_transport, ) from unstract.core.execution_dispatch import DispatchHandle, signature_to_continuation +from unstract.workflow_execution.executor_rpc import ( + ExecResultRow, + PgExecutionDispatcher, + resolve_pg_transport, +) -_MOD = "queue_backend.pg_queue.executor_rpc" +_WMOD = "queue_backend.pg_queue.executor_rpc" +_SMOD = "unstract.workflow_execution.executor_rpc" -def _completed(result: dict) -> dict: - return {"status": "completed", "result": result, "error": ""} +def _ctx(org: str | None = "org1") -> MagicMock: + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "run-1" + c.organization_id = org + c.to_dict.return_value = {"run_id": "run-1"} + return c def _ok_result() -> dict: return {"success": True, "data": {"x": 1}, "metadata": {}, "error": None} -class TestPgExecutionDispatcherDispatch: - """The load-bearing contract: never raises; timeout/failure → failure result. +def _completed(result: dict) -> ExecResultRow: + return ExecResultRow(status="completed", result=result, error="") + - DB-free — the ``_enqueue`` and ``_wait_for_result`` halves are mocked. +class _FakeTransport: + """Records ``enqueue`` calls and returns a configured result for the poll. + + Lets the shared dispatcher's logic be exercised without any DB. """ - @staticmethod - def _ctx() -> MagicMock: - c = MagicMock() - c.executor_name = "legacy" - c.run_id = "r" - c.organization_id = "o" - c.to_dict.return_value = {"run_id": "r"} - return c + def __init__(self, *, wait_return=None, wait_raises=None, enqueue_raises=None): + self.enqueue_calls: list[dict] = [] + self.wait_timeouts: list[float] = [] + self._wait_return = wait_return + self._wait_raises = wait_raises + self._enqueue_raises = enqueue_raises + + def enqueue(self, **kwargs) -> None: + self.enqueue_calls.append(kwargs) + if self._enqueue_raises is not None: + raise self._enqueue_raises + + def wait_for_result(self, reply_key, timeout): + self.wait_timeouts.append(timeout) + if self._wait_raises is not None: + raise self._wait_raises + return self._wait_return + +# --- Shared contract: PgExecutionDispatcher never-raises + result interpretation --- + + +class TestSharedDispatchContract: def test_enqueue_failure_returns_failure_not_raise(self): - with patch.object( - PgExecutionDispatcher, "_enqueue", side_effect=RuntimeError("db down") - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - assert "RuntimeError" in res.error + d = PgExecutionDispatcher(_FakeTransport(enqueue_raises=RuntimeError("db down"))) + res = d.dispatch(_ctx(), timeout=5) + assert res.success is False and "RuntimeError" in res.error def test_wait_failure_returns_failure_not_raise(self): - with ( - patch.object(PgExecutionDispatcher, "_enqueue"), - patch.object( - PgExecutionDispatcher, - "_wait_for_result", - side_effect=RuntimeError("conn died"), - ), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - assert "RuntimeError" in res.error + d = PgExecutionDispatcher(_FakeTransport(wait_raises=RuntimeError("conn died"))) + res = d.dispatch(_ctx(), timeout=5) + assert res.success is False and "RuntimeError" in res.error def test_timeout_returns_failure(self): - with ( - patch.object(PgExecutionDispatcher, "_enqueue"), - patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=None), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=3) - assert res.success is False - assert "within 3s" in res.error + d = PgExecutionDispatcher(_FakeTransport(wait_return=None)) + res = d.dispatch(_ctx(), timeout=3) + assert res.success is False and "within 3s" in res.error def test_completed_row_returns_result(self): - with ( - patch.object(PgExecutionDispatcher, "_enqueue"), - patch.object( - PgExecutionDispatcher, - "_wait_for_result", - return_value=_completed(_ok_result()), - ), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is True + d = PgExecutionDispatcher(_FakeTransport(wait_return=_completed(_ok_result()))) + assert d.dispatch(_ctx(), timeout=5).success is True def test_failed_row_returns_error(self): - row = {"status": "failed", "result": None, "error": "boom"} - with ( - patch.object(PgExecutionDispatcher, "_enqueue"), - patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - assert res.error == "boom" + row = ExecResultRow(status="failed", result=None, error="boom") + res = PgExecutionDispatcher(_FakeTransport(wait_return=row)).dispatch(_ctx(), timeout=5) + assert res.success is False and res.error == "boom" def test_failed_row_empty_error_falls_back(self): - row = {"status": "failed", "result": None, "error": ""} - with ( - patch.object(PgExecutionDispatcher, "_enqueue"), - patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - assert "executor task failed" in res.error + row = ExecResultRow(status="failed", result=None, error="") + res = PgExecutionDispatcher(_FakeTransport(wait_return=row)).dispatch(_ctx(), timeout=5) + assert res.success is False and "executor task failed" in res.error def test_completed_but_result_none_is_failure(self): - row = {"status": "completed", "result": None, "error": ""} - with ( - patch.object(PgExecutionDispatcher, "_enqueue"), - patch.object(PgExecutionDispatcher, "_wait_for_result", return_value=row), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False + row = ExecResultRow(status="completed", result=None, error="") + assert PgExecutionDispatcher(_FakeTransport(wait_return=row)).dispatch(_ctx(), timeout=5).success is False def test_malformed_completed_row_is_failure_not_raise(self): - with ( - patch.object(PgExecutionDispatcher, "_enqueue"), - patch.object( - PgExecutionDispatcher, - "_wait_for_result", - return_value=_completed({"bad": "shape"}), - ), - ): - res = PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - assert res.success is False - assert "Malformed" in res.error + d = PgExecutionDispatcher(_FakeTransport(wait_return=_completed({"bad": "shape"}))) + res = d.dispatch(_ctx(), timeout=5) + assert res.success is False and "Malformed" in res.error def test_timeout_none_reads_env_then_default(self, monkeypatch): monkeypatch.setenv("EXECUTOR_RESULT_TIMEOUT", "42") - seen = {} - - def fake_wait(reply_key, timeout): - seen["timeout"] = timeout - return None - - with ( - patch.object(PgExecutionDispatcher, "_enqueue"), - patch.object( - PgExecutionDispatcher, "_wait_for_result", side_effect=fake_wait - ), - ): - # No explicit timeout arg → falls back to the env/default. - PgExecutionDispatcher().dispatch(self._ctx()) - assert seen["timeout"] == 42 + t = _FakeTransport(wait_return=None) + PgExecutionDispatcher(t).dispatch(_ctx()) # no explicit timeout + assert t.wait_timeouts == [42] def test_timeout_none_bad_env_falls_back_to_default(self, monkeypatch): monkeypatch.setenv("EXECUTOR_RESULT_TIMEOUT", "not-an-int") - seen = {} + t = _FakeTransport(wait_return=None) + PgExecutionDispatcher(t).dispatch(_ctx()) # must not raise + assert t.wait_timeouts == [3600] + + def test_dispatch_request_reply_enqueues_reply_key_only(self): + t = _FakeTransport(wait_return=_completed(_ok_result())) + PgExecutionDispatcher(t).dispatch(_ctx(), timeout=5) + (kw,) = t.enqueue_calls + assert kw["queue"] == "celery_executor_legacy" and kw["org_id"] == "org1" + assert kw["reply_key"] # request-reply marker + assert kw.get("task_id") is None and kw.get("on_success") is None - def fake_wait(reply_key, timeout): - seen["timeout"] = timeout - return None + def test_dispatch_async_is_fire_and_forget(self): + t = _FakeTransport() + task_id = PgExecutionDispatcher(t).dispatch_async(_ctx()) + (kw,) = t.enqueue_calls + assert kw["task_id"] == task_id and kw.get("reply_key") is None + assert kw.get("on_success") is None and kw.get("on_error") is None + + def test_dispatch_async_propagates_enqueue_failure(self): + # Documented asymmetry vs the never-raises dispatch: a fire-and-forget enqueue + # error propagates (the caller has no result object to fail into). + t = _FakeTransport(enqueue_raises=RuntimeError("db down")) + with pytest.raises(RuntimeError, match="db down"): + PgExecutionDispatcher(t).dispatch_async(_ctx()) - with ( - patch.object(PgExecutionDispatcher, "_enqueue"), - patch.object( - PgExecutionDispatcher, "_wait_for_result", side_effect=fake_wait - ), - ): - PgExecutionDispatcher().dispatch(self._ctx()) # must not raise - assert seen["timeout"] == 3600 # _DEFAULT_TIMEOUT + @staticmethod + def _sig(task: str): + return MagicMock( + task=task, args=(), kwargs={"callback_kwargs": {"room": "r1"}}, + options={"queue": "ide_callback"}, + ) + def test_dispatch_with_callback_translates_both_signatures(self): + t = _FakeTransport() + handle = PgExecutionDispatcher(t).dispatch_with_callback( + _ctx(), on_success=self._sig("ide_prompt_complete"), + on_error=self._sig("ide_prompt_error"), task_id="tid-7", + ) + assert handle.id == "tid-7" + (kw,) = t.enqueue_calls + assert kw["on_success"] == { + "task_name": "ide_prompt_complete", + "kwargs": {"callback_kwargs": {"room": "r1"}}, + "queue": "ide_callback", + } + assert kw["on_error"]["task_name"] == "ide_prompt_error" # on_error translated + assert kw["task_id"] == "tid-7" and kw.get("reply_key") is None -def _ctx(org: str | None = "org1") -> MagicMock: - c = MagicMock() - c.executor_name = "legacy" - c.run_id = "run-1" - c.organization_id = org - return c + def test_dispatch_with_callback_defaults_task_id(self): + t = _FakeTransport() + handle = PgExecutionDispatcher(t).dispatch_with_callback( + _ctx(), on_success=self._sig("ide_prompt_complete") + ) + # No task_id passed → a uuid is generated, echoed on the handle AND the payload. + assert handle.id + assert t.enqueue_calls[0]["task_id"] == handle.id -class TestResolveExecutorTransport: - def test_master_gate_off_is_celery(self, monkeypatch): - monkeypatch.delenv("PG_QUEUE_TRANSPORT_ENABLED", raising=False) - monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with patch(f"{_MOD}.check_feature_flag_status") as flag: - assert resolve_executor_transport(_ctx()) is False - flag.assert_not_called() # gate off → Flipt never consulted +# --- Shared gate: resolve_pg_transport (master-gated, then Flipt, fail-closed) --- + + +class TestResolvePgTransport: + def test_master_gate_off_is_celery(self): + with patch(f"{_SMOD}.check_feature_flag_status") as flag: + assert resolve_pg_transport(_ctx(), master_gate_enabled=False) is False + flag.assert_not_called() def test_flipt_unavailable_is_celery(self, monkeypatch): - monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") - with patch(f"{_MOD}.check_feature_flag_status") as flag: - assert resolve_executor_transport(_ctx()) is False + with patch(f"{_SMOD}.check_feature_flag_status") as flag: + assert resolve_pg_transport(_ctx(), master_gate_enabled=True) is False flag.assert_not_called() def test_flag_true_is_pg_keyed_on_org(self, monkeypatch): - monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with patch( - f"{_MOD}.check_feature_flag_status", return_value=True - ) as flag: - assert resolve_executor_transport(_ctx("orgX")) is True + with patch(f"{_SMOD}.check_feature_flag_status", return_value=True) as flag: + assert resolve_pg_transport(_ctx("orgX"), master_gate_enabled=True) is True assert flag.call_args.kwargs["entity_id"] == "orgX" - # The single shared PG-queue flag (not a per-subsystem flag). assert flag.call_args.kwargs["flag_key"] == "pg_queue_enabled" def test_flag_false_is_celery(self, monkeypatch): - monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with patch(f"{_MOD}.check_feature_flag_status", return_value=False): - assert resolve_executor_transport(_ctx()) is False + with patch(f"{_SMOD}.check_feature_flag_status", return_value=False): + assert resolve_pg_transport(_ctx(), master_gate_enabled=True) is False - def test_flipt_error_fails_closed_to_celery(self, monkeypatch): - monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") + def test_flipt_error_fails_closed(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with patch( - f"{_MOD}.check_feature_flag_status", side_effect=RuntimeError("down") - ): - assert resolve_executor_transport(_ctx()) is False + with patch(f"{_SMOD}.check_feature_flag_status", side_effect=RuntimeError("x")): + assert resolve_pg_transport(_ctx(), master_gate_enabled=True) is False def test_org_less_context_buckets_on_run_id(self, monkeypatch): - """No org → entity_id falls back to run_id and org is absent from context. - - Guards the org-less bucketing so cross-org/run-only contexts resolve - deterministically instead of shipping a bogus "None" org. - """ - monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with patch( - f"{_MOD}.check_feature_flag_status", return_value=True - ) as flag: - assert resolve_executor_transport(_ctx(org=None)) is True + with patch(f"{_SMOD}.check_feature_flag_status", return_value=True) as flag: + assert resolve_pg_transport(_ctx(org=None), master_gate_enabled=True) is True assert flag.call_args.kwargs["entity_id"] == "run-1" assert "organization_id" not in flag.call_args.kwargs["context"] -class TestPgExecutionDispatcherEnqueueWiring: - """The actual PG-transport wiring: queue name, payload shape, org_id. +# --- Shared routing: RoutingExecutionDispatcher (zero-regression) --- - These are the *only* routing/identity carried on the PG path (Celery headers - are dropped), so a bug here misroutes or breaks org-fairness. ``to_payload`` - runs for real; only ``PgQueueClient`` and the wait are mocked. - """ +class TestRoutingDispatch: @staticmethod - def _ctx(): - c = MagicMock() - c.executor_name = "legacy" - c.run_id = "r" - c.organization_id = "org9" - c.to_dict.return_value = {"run_id": "r"} - return c - - def test_enqueue_sends_queue_payload_and_org(self): - client = MagicMock() - client.__enter__.return_value = client # `with PgQueueClient() as c` → c is client - with ( - patch(f"{_MOD}.PgQueueClient", return_value=client), - patch.object( - PgExecutionDispatcher, - "_wait_for_result", - return_value=_completed(_ok_result()), - ), - ): - PgExecutionDispatcher().dispatch(self._ctx(), timeout=5) - client.send.assert_called_once() - args, kwargs = client.send.call_args - queue_arg, payload_arg = args[0], args[1] - assert queue_arg == "celery_executor_legacy" - assert kwargs["org_id"] == "org9" - assert payload_arg["task_name"] == "execute_extraction" - assert payload_arg["args"] == [{"run_id": "r"}] - assert payload_arg["reply_key"] # request-reply marker present (a uuid) - - -class TestRoutingZeroRegression: - @staticmethod - def _build(): - # Patch both sub-dispatchers at construction; the instances are captured - # in __init__ so they remain mocked after the context exits. - with ( - patch(f"{_MOD}.ExecutionDispatcher") as celery_cls, - patch(f"{_MOD}.PgExecutionDispatcher") as pg_cls, - ): - dispatcher = RoutingExecutionDispatcher(celery_app="app") - return dispatcher, celery_cls.return_value, pg_cls.return_value + def _build(route_to_pg: bool): + celery, pg = MagicMock(), MagicMock() + d = RoutingExecutionDispatcher( + celery=celery, pg=pg, resolve=lambda _ctx: route_to_pg + ) + return d, celery, pg def test_gate_off_forwards_timeout_and_headers_to_celery(self): - """Zero-regression: gate off → Celery gets timeout AND headers unchanged.""" - dispatcher, celery, pg = self._build() + d, celery, pg = self._build(route_to_pg=False) ctx = _ctx() hdrs = {"x-fairness-key": {"org_id": "o"}} - with patch(f"{_MOD}.resolve_executor_transport", return_value=False): - dispatcher.dispatch(ctx, timeout=9, headers=hdrs) + d.dispatch(ctx, timeout=9, headers=hdrs) celery.dispatch.assert_called_once_with(ctx, timeout=9, headers=hdrs) - pg.dispatch.assert_not_called() # the zero-regression guarantee + pg.dispatch.assert_not_called() def test_gate_on_passes_timeout_to_pg_and_drops_headers(self): - """Gate on → PG gets the timeout but NOT the Celery headers (intentional).""" - dispatcher, celery, pg = self._build() + d, celery, pg = self._build(route_to_pg=True) ctx = _ctx() - with patch(f"{_MOD}.resolve_executor_transport", return_value=True): - dispatcher.dispatch(ctx, timeout=7, headers={"x-fairness-key": {"o": 1}}) - pg.dispatch.assert_called_once_with(ctx, timeout=7) - assert "headers" not in pg.dispatch.call_args.kwargs + d.dispatch(ctx, timeout=7, headers={"x-fairness-key": {"o": 1}}) + pg.dispatch.assert_called_once_with(ctx, timeout=7) # headers dropped celery.dispatch.assert_not_called() def test_async_and_callback_stay_celery_when_gate_off(self): - """Zero-regression: gate off → async/callback delegate to Celery unchanged.""" - dispatcher, celery, pg = self._build() - with patch(f"{_MOD}.resolve_executor_transport", return_value=False): - dispatcher.dispatch_async(_ctx(), headers={"h": 1}) - dispatcher.dispatch_with_callback(_ctx(), on_success="s", on_error="e") + d, celery, pg = self._build(route_to_pg=False) + d.dispatch_async(_ctx(), headers={"h": 1}) + d.dispatch_with_callback(_ctx(), on_success="s", on_error="e") celery.dispatch_async.assert_called_once() celery.dispatch_with_callback.assert_called_once() pg.dispatch_async.assert_not_called() pg.dispatch_with_callback.assert_not_called() def test_async_and_callback_route_to_pg_when_gated(self): - """Gate on (③c) → async/callback take the PG self-chained path.""" - dispatcher, celery, pg = self._build() - with patch(f"{_MOD}.resolve_executor_transport", return_value=True): - dispatcher.dispatch_async(_ctx()) - dispatcher.dispatch_with_callback( - _ctx(), on_success="s", on_error="e", task_id="t" - ) + d, celery, pg = self._build(route_to_pg=True) + d.dispatch_async(_ctx()) + d.dispatch_with_callback(_ctx(), on_success="s", on_error="e", task_id="t") pg.dispatch_async.assert_called_once() pg.dispatch_with_callback.assert_called_once() - # PG carries callbacks in the payload, not Celery headers → no header leak. assert "headers" not in pg.dispatch_with_callback.call_args.kwargs celery.dispatch_async.assert_not_called() celery.dispatch_with_callback.assert_not_called() -class TestPgAsyncCallbackWiring: - """PG fire-and-forget + self-chained-callback enqueue shapes. +# --- Workers adapter: PgClientQueueTransport + env gate + factory wiring --- - ``to_payload`` runs for real; only ``PgQueueClient`` is mocked. Pins that the - async path carries NO reply_key (it must not block a consumer) and the callback - path carries the translated continuations + the tracking task_id. - """ +class TestWorkersAdapter: @staticmethod def _ctx(): c = MagicMock() c.executor_name = "legacy" c.run_id = "r" - c.organization_id = "org9" - c.to_dict.return_value = {"run_id": "r", "organization_id": "org9"} + c.to_dict.return_value = {"run_id": "r"} return c @staticmethod def _client(): client = MagicMock() - client.__enter__.return_value = client + client.__enter__.return_value = client # `with PgQueueClient() as c` return client - def test_dispatch_async_is_fire_and_forget(self): + def test_enqueue_sends_queue_payload_and_org(self): client = self._client() - with patch(f"{_MOD}.PgQueueClient", return_value=client): - task_id = PgExecutionDispatcher().dispatch_async(self._ctx()) + with patch(f"{_WMOD}.PgQueueClient", return_value=client): + PgClientQueueTransport().enqueue( + queue="celery_executor_legacy", context=self._ctx(), + org_id="org9", reply_key="rk1", + ) client.send.assert_called_once() queue_arg, payload_arg = client.send.call_args.args[:2] assert queue_arg == "celery_executor_legacy" assert client.send.call_args.kwargs["org_id"] == "org9" assert payload_arg["task_name"] == "execute_extraction" - assert payload_arg["task_id"] == task_id - # No reply_key (would make a consumer try to store a reply) and no callback. - assert "reply_key" not in payload_arg - assert "on_success" not in payload_arg - assert "on_error" not in payload_arg + assert payload_arg["args"] == [{"run_id": "r"}] + assert payload_arg["reply_key"] == "rk1" - def test_dispatch_with_callback_carries_continuations(self): + def test_enqueue_carries_continuations(self): client = self._client() - on_s = MagicMock( - task="ide_prompt_complete", - args=(), - kwargs={"callback_kwargs": {"room": "r1"}}, - options={"queue": "ide_callback"}, - ) - on_e = MagicMock( - task="ide_prompt_error", - args=(), - kwargs={"callback_kwargs": {"room": "r1"}}, - options={"queue": "ide_callback"}, - ) - with patch(f"{_MOD}.PgQueueClient", return_value=client): - handle = PgExecutionDispatcher().dispatch_with_callback( - self._ctx(), on_success=on_s, on_error=on_e, task_id="tid-7" + spec = {"task_name": "ide_prompt_complete", "kwargs": {}, "queue": "ide_callback"} + with patch(f"{_WMOD}.PgQueueClient", return_value=client): + PgClientQueueTransport().enqueue( + queue="celery_executor_legacy", context=self._ctx(), org_id="o", + on_success=spec, task_id="tid-7", ) - assert handle.id == "tid-7" # call sites read .id off the handle - payload_arg = client.send.call_args.args[1] - assert payload_arg["on_success"] == { - "task_name": "ide_prompt_complete", - "kwargs": {"callback_kwargs": {"room": "r1"}}, - "queue": "ide_callback", - } - assert payload_arg["on_error"]["task_name"] == "ide_prompt_error" - assert payload_arg["task_id"] == "tid-7" - assert "reply_key" not in payload_arg # callback, not request-reply + payload = client.send.call_args.args[1] + assert payload["on_success"] == spec and payload["task_id"] == "tid-7" + assert "reply_key" not in payload # callback dispatch, not request-reply + + def test_wait_for_result_folds_dict_to_row(self): + rb = MagicMock() + rb.__enter__.return_value = rb + rb.wait_for_result.return_value = {"status": "completed", "result": {"a": 1}, "error": ""} + with patch(f"{_WMOD}.PgResultBackend", return_value=rb): + row = PgClientQueueTransport().wait_for_result("rk", 5) + assert isinstance(row, ExecResultRow) + assert row.status == "completed" and row.result == {"a": 1} + + def test_wait_for_result_none_passes_through(self): + rb = MagicMock() + rb.__enter__.return_value = rb + rb.wait_for_result.return_value = None + with patch(f"{_WMOD}.PgResultBackend", return_value=rb): + assert PgClientQueueTransport().wait_for_result("rk", 5) is None + + def test_resolve_reads_env_master_gate(self, monkeypatch): + monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") + with patch(f"{_WMOD}.resolve_pg_transport", return_value=True) as r: + assert resolve_executor_transport(self._ctx()) is True + assert r.call_args.kwargs["master_gate_enabled"] is True - def test_dispatch_with_callback_defaults_task_id(self): - client = self._client() - with patch(f"{_MOD}.PgQueueClient", return_value=client): - handle = PgExecutionDispatcher().dispatch_with_callback(self._ctx()) - # No task_id passed → a uuid is generated and echoed on the handle + payload. - assert handle.id - assert client.send.call_args.args[1]["task_id"] == handle.id + def test_resolve_env_off_is_false(self, monkeypatch): + monkeypatch.delenv("PG_QUEUE_TRANSPORT_ENABLED", raising=False) + with patch(f"{_WMOD}.resolve_pg_transport", return_value=False) as r: + resolve_executor_transport(self._ctx()) + assert r.call_args.kwargs["master_gate_enabled"] is False + def test_factory_wires_routing_with_workers_transport(self): + d = get_executor_dispatcher(celery_app="app") + assert isinstance(d, RoutingExecutionDispatcher) + # The PG dispatcher is wired with the workers psycopg2 transport, and the gate + # is the workers env-master-gate resolver. + assert isinstance(d._pg._transport, PgClientQueueTransport) + assert d._resolve is resolve_executor_transport -class TestSharedDispatchHelpers: - """The transport-agnostic helpers lifted to ``unstract.core`` (shared by the - backend + workers executor-RPC mirrors). Tested once here, not per-mirror. - """ +# --- Core helpers (unchanged; the shared signature/handle primitives) --- + + +class TestSharedDispatchHelpers: def test_signature_none_passes_through(self): assert signature_to_continuation(None) is None def test_signature_translates_task_kwargs_and_queue(self): - sig = MagicMock( - task="ide_prompt_complete", - args=(), # a real kwargs-only Celery Signature has empty .args - kwargs={"callback_kwargs": {"room": "r1"}}, - options={"queue": "ide_callback"}, - ) + sig = MagicMock(task="ide_prompt_complete", args=(), + kwargs={"callback_kwargs": {"room": "r1"}}, + options={"queue": "ide_callback"}) assert signature_to_continuation(sig) == { "task_name": "ide_prompt_complete", "kwargs": {"callback_kwargs": {"room": "r1"}}, @@ -435,18 +377,13 @@ def test_signature_missing_task_fails_fast(self): signature_to_continuation(sig) def test_signature_with_positional_args_fails_fast(self): - sig = MagicMock( - task="ide_prompt_complete", - args=("pos",), - kwargs={}, - options={"queue": "ide_callback"}, - ) + sig = MagicMock(task="ide_prompt_complete", args=("pos",), kwargs={}, + options={"queue": "ide_callback"}) with pytest.raises(ValueError, match="positional args"): signature_to_continuation(sig) def test_dispatch_handle_exposes_only_id(self): handle = DispatchHandle("tid-1") assert handle.id == "tid-1" - # __slots__ → no stray attributes (callers must not poke at .get()/.result). with pytest.raises(AttributeError): handle.result = 1 # type: ignore[attr-defined] diff --git a/workers/uv.lock b/workers/uv.lock index 2be2e6b718..96eb092d57 100644 --- a/workers/uv.lock +++ b/workers/uv.lock @@ -5016,6 +5016,7 @@ dependencies = [ { name = "unstract-core" }, { name = "unstract-filesystem" }, { name = "unstract-flags" }, + { name = "unstract-sdk1" }, { name = "unstract-tool-registry" }, { name = "unstract-tool-sandbox" }, ] @@ -5025,6 +5026,7 @@ requires-dist = [ { name = "unstract-core", editable = "../unstract/core" }, { name = "unstract-filesystem", editable = "../unstract/filesystem" }, { name = "unstract-flags", editable = "../unstract/flags" }, + { name = "unstract-sdk1", editable = "../unstract/sdk1" }, { name = "unstract-tool-registry", editable = "../unstract/tool-registry" }, { name = "unstract-tool-sandbox", editable = "../unstract/tool-sandbox" }, ] From ea7a97b3da7ac3b9f6732db0dec35b2eff400d79 Mon Sep 17 00:00:00 2001 From: ali Date: Mon, 22 Jun 2026 13:21:27 +0530 Subject: [PATCH 34/65] =?UTF-8?q?UN-3445=20[TOOL]=20pg=5Fbenchmark=20?= =?UTF-8?q?=E2=80=94=20PG-vs-Celery=20execution=20benchmark=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev-phase validation tooling (not product code; no PR ceremony) to prove PG >= Celery before any Celery decommission. Self-contained top-level package, no imports into backend/workers — non-regressive by construction. S1 (measurement spine): `report` + `queue-depth` read the backend DB directly and compare executions per transport. Transport classified post-hoc from the persistent queue_message_id/task_id columns; headline signal is server-measured execution_time + per-file parallelism (sum(file_times)/execution_time). S2 (load generation): `run` drives N executions at concurrency C against a running stack, polls each to terminal, and reports wall-clock / server / overhead / http latency per transport plus throughput. Transport observed per-execution so PG and Celery batches bucket correctly even mid-rollout. 26 unit tests (pure stats + classification + probe timing with injected clock/faked I/O). S1 dev-tested against the live dev DB. Co-Authored-By: Claude Opus 4.8 --- pg_benchmark/.gitignore | 5 + pg_benchmark/README.md | 67 ++++++++ pg_benchmark/pg_benchmark/__init__.py | 1 + pg_benchmark/pg_benchmark/__main__.py | 10 ++ pg_benchmark/pg_benchmark/cli.py | 177 +++++++++++++++++++++ pg_benchmark/pg_benchmark/config.py | 53 +++++++ pg_benchmark/pg_benchmark/db.py | 210 +++++++++++++++++++++++++ pg_benchmark/pg_benchmark/probe.py | 121 +++++++++++++++ pg_benchmark/pg_benchmark/report.py | 156 +++++++++++++++++++ pg_benchmark/pg_benchmark/runner.py | 76 +++++++++ pg_benchmark/pg_benchmark/stats.py | 74 +++++++++ pg_benchmark/pg_benchmark/trigger.py | 128 ++++++++++++++++ pg_benchmark/pyproject.toml | 14 ++ pg_benchmark/requirements.txt | 5 + pg_benchmark/tests/test_load.py | 212 ++++++++++++++++++++++++++ pg_benchmark/tests/test_stats.py | 113 ++++++++++++++ 16 files changed, 1422 insertions(+) create mode 100644 pg_benchmark/.gitignore create mode 100644 pg_benchmark/README.md create mode 100644 pg_benchmark/pg_benchmark/__init__.py create mode 100644 pg_benchmark/pg_benchmark/__main__.py create mode 100644 pg_benchmark/pg_benchmark/cli.py create mode 100644 pg_benchmark/pg_benchmark/config.py create mode 100644 pg_benchmark/pg_benchmark/db.py create mode 100644 pg_benchmark/pg_benchmark/probe.py create mode 100644 pg_benchmark/pg_benchmark/report.py create mode 100644 pg_benchmark/pg_benchmark/runner.py create mode 100644 pg_benchmark/pg_benchmark/stats.py create mode 100644 pg_benchmark/pg_benchmark/trigger.py create mode 100644 pg_benchmark/pyproject.toml create mode 100644 pg_benchmark/requirements.txt create mode 100644 pg_benchmark/tests/test_load.py create mode 100644 pg_benchmark/tests/test_stats.py diff --git a/pg_benchmark/.gitignore b/pg_benchmark/.gitignore new file mode 100644 index 0000000000..6fecb2bdb8 --- /dev/null +++ b/pg_benchmark/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ +.venv/ diff --git a/pg_benchmark/README.md b/pg_benchmark/README.md new file mode 100644 index 0000000000..6ba438dd1a --- /dev/null +++ b/pg_benchmark/README.md @@ -0,0 +1,67 @@ +# pg_benchmark — PG-queue vs Celery execution benchmark harness + +A self-contained harness to compare the **PG-queue transport** against the +**Celery/RabbitMQ transport** for Unstract workflow executions — functional +parity, performance, and (later) load/soak. This is the **gate** that must show +`PG ≥ Celery` before any Celery decommission (epic UN-3445). + +It is dev/ops tooling, not product code: a new top-level directory, no imports +into backend/workers, non-regressive by construction. + +## Latency model (why it measures what it measures) + +The harness deliberately leans on **persistent, server-measured** signals: + +| Signal | Source | Persistent? | +|---|---|---| +| `execution_time` (server) | `workflow_execution.execution_time` | ✅ truest cross-transport number — excludes harness HTTP/poll overhead | +| per-file `execution_time` | `workflow_file_execution.execution_time` | ✅ exposes **parallelism** (the key fan-out signal) | +| transport class | `queue_message_id` / `task_id` columns | ✅ survive on the row after the queue message is deleted | +| wall-clock E2E | harness client clock (trigger → terminal) | ✅ (only when the harness triggers the run) | +| enqueue→pickup | `pg_queue_message.enqueued_at` / `vt` | ❌ **ephemeral** — row deleted on ack; only observable by *live sampling* during a run | + +**Parallelism** `= sum(file_times) / execution_time`: `≈ N` means all N files +overlapped (ideal fan-out), `≈ 1` means they ran serially. This directly +measures the serial-fileproc concern on the PG path. + +> Transport micro-latency (enqueue→pickup, the SKIP-LOCKED-poll-vs-push KPI) is +> **not** readable post-hoc — those queue rows are deleted on ack. It needs a +> live sampler that watches `pg_queue_message` while a run is in flight; that is +> a later slice. + +## Status — slices + +- **S1 (this slice): measurement spine.** `report` + `queue-depth` read the DB + directly and print a per-transport latency comparison. Read-only. +- **S2: load generation.** `run` — trigger N executions at concurrency C against + a running stack, flip the transport, collect wall-clock + server latency. +- **S3: live transport sampler.** Capture enqueue→pickup + queue depth during a + run (PG-only). +- **S4: functional parity matrix.** Flag OFF vs ON across workflow shapes. + +## Usage + +```bash +cd pg_benchmark +pip install -r requirements.txt # or use the backend .venv (has psycopg2) + +# Compare the last 200 finished executions, all transports side by side: +python -m pg_benchmark report --last 200 + +# Just the PG path: +python -m pg_benchmark report --last 200 --transport pg + +# Current live queue depth per queue: +python -m pg_benchmark queue-depth +``` + +DB connection defaults mirror the local docker-compose stack +(`localhost:5432/unstract_db`, schema `unstract`). Override via `DB_HOST`, +`DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` env vars or the `--db-*` flags. + +## Tests + +```bash +cd pg_benchmark +pytest # pure stats + classification + parallelism (no DB needed) +``` diff --git a/pg_benchmark/pg_benchmark/__init__.py b/pg_benchmark/pg_benchmark/__init__.py new file mode 100644 index 0000000000..118b9b3b67 --- /dev/null +++ b/pg_benchmark/pg_benchmark/__init__.py @@ -0,0 +1 @@ +"""PG-queue vs Celery execution benchmark harness.""" diff --git a/pg_benchmark/pg_benchmark/__main__.py b/pg_benchmark/pg_benchmark/__main__.py new file mode 100644 index 0000000000..3b2648627a --- /dev/null +++ b/pg_benchmark/pg_benchmark/__main__.py @@ -0,0 +1,10 @@ +"""``python -m pg_benchmark`` entrypoint.""" + +from __future__ import annotations + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pg_benchmark/pg_benchmark/cli.py b/pg_benchmark/pg_benchmark/cli.py new file mode 100644 index 0000000000..08757b6cc1 --- /dev/null +++ b/pg_benchmark/pg_benchmark/cli.py @@ -0,0 +1,177 @@ +"""CLI entrypoint for the PG-vs-Celery benchmark harness. + +Slice 1 ships the measurement spine: ``report`` reads finished executions +straight from the DB and prints a per-transport latency comparison. Load +generation (``run``) and live transport sampling are later slices and plug into +these same readers. +""" + +from __future__ import annotations + +import argparse +import os +import sys + +from .config import DbConfig +from .db import Transport, connect, fetch_recent, queue_depth +from .probe import RunResult +from .report import build_reports, render, render_load +from .runner import run_load +from .trigger import TriggerConfig + +_TRANSPORT_CHOICES = {t.value: t for t in Transport} + + +def _add_db_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--db-host", help="override DB_HOST") + parser.add_argument("--db-port", type=int, help="override DB_PORT") + parser.add_argument("--db-name", help="override DB_NAME") + parser.add_argument("--db-user", help="override DB_USER") + parser.add_argument("--db-password", help="override DB_PASSWORD") + + +def _db_config(args: argparse.Namespace) -> DbConfig: + base = DbConfig.from_env() + return DbConfig( + host=args.db_host or base.host, + port=args.db_port or base.port, + name=args.db_name or base.name, + user=args.db_user or base.user, + password=args.db_password or base.password, + schema=base.schema, + ) + + +def _cmd_report(args: argparse.Namespace) -> int: + transport = _TRANSPORT_CHOICES.get(args.transport) if args.transport else None + conn = connect(_db_config(args)) + try: + executions = fetch_recent(conn, limit=args.last, transport=transport) + print( + f"Sampled {len(executions)} terminal executions " + f"(last {args.last}{', ' + args.transport if args.transport else ''}).\n" + ) + print(render(build_reports(executions))) + finally: + conn.close() + return 0 + + +def _cmd_queue_depth(args: argparse.Namespace) -> int: + conn = connect(_db_config(args)) + try: + depth = queue_depth(conn) + if not depth: + print("pg_queue_message is empty.") + else: + for name, count in sorted(depth.items(), key=lambda kv: -kv[1]): + print(f" {name:<28} {count}") + finally: + conn.close() + return 0 + + +def _cmd_run(args: argparse.Namespace) -> int: + api_key = args.api_key or os.environ.get("PGBENCH_API_KEY", "") + if not args.path: + print("error: --path (API deployment execute path) is required", file=sys.stderr) + return 2 + if not api_key: + print("error: --api-key or PGBENCH_API_KEY is required", file=sys.stderr) + return 2 + trigger_cfg = TriggerConfig( + base_url=args.base_url, + path=args.path, + api_key=api_key, + files=args.file or [], + auth_header=args.auth_header, + auth_prefix=args.auth_prefix, + ) + db_cfg = _db_config(args) + done = {"n": 0} + + def _progress(result: RunResult) -> None: + done["n"] += 1 + tag = (result.transport.value if result.transport else "?").upper() + state = "ok" if result.ok else f"FAIL({result.error})" + wc = f"{result.wall_clock_e2e:.1f}s" if result.wall_clock_e2e else "-" + print(f" [{done['n']}/{args.n}] {tag} {state} wall={wc}", file=sys.stderr) + + print( + f"Driving {args.n} executions at concurrency {args.concurrency} " + f"against {trigger_cfg.url} ...\n", + file=sys.stderr, + ) + outcome = run_load( + trigger_cfg, + db_cfg, + n=args.n, + concurrency=args.concurrency, + poll_interval=args.poll_interval, + timeout=args.timeout, + on_result=_progress, + ) + print() + print(render_load(outcome)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="pg_benchmark", + description="PG-queue vs Celery execution benchmark harness.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + report = sub.add_parser( + "report", help="compare recent executions by transport (read-only)" + ) + report.add_argument( + "--last", type=int, default=100, help="how many recent executions to sample" + ) + report.add_argument( + "--transport", + choices=sorted(_TRANSPORT_CHOICES), + help="restrict to one transport (default: all, side by side)", + ) + _add_db_args(report) + report.set_defaults(func=_cmd_report) + + qd = sub.add_parser("queue-depth", help="current pg_queue_message depth per queue") + _add_db_args(qd) + qd.set_defaults(func=_cmd_queue_depth) + + run = sub.add_parser( + "run", help="drive N executions at concurrency C and measure latency" + ) + run.add_argument("--n", type=int, default=10, help="total executions to trigger") + run.add_argument( + "--concurrency", type=int, default=4, help="max executions in flight at once" + ) + run.add_argument( + "--base-url", default=os.environ.get("PGBENCH_BASE_URL", "http://localhost:8000") + ) + run.add_argument( + "--path", + default=os.environ.get("PGBENCH_DEPLOY_PATH", ""), + help="API deployment execute path, e.g. /deployment/api///", + ) + run.add_argument("--api-key", help="API key (or set PGBENCH_API_KEY)") + run.add_argument("--file", action="append", help="local file to upload (repeatable)") + run.add_argument("--auth-header", default="Authorization") + run.add_argument("--auth-prefix", default="Bearer ") + run.add_argument("--poll-interval", type=float, default=0.5) + run.add_argument("--timeout", type=float, default=600.0) + _add_db_args(run) + run.set_defaults(func=_cmd_run) + + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pg_benchmark/pg_benchmark/config.py b/pg_benchmark/pg_benchmark/config.py new file mode 100644 index 0000000000..1e65ff2af9 --- /dev/null +++ b/pg_benchmark/pg_benchmark/config.py @@ -0,0 +1,53 @@ +"""Benchmark configuration — DB connection + backend endpoint. + +Defaults mirror the local docker-compose stack (``backend/backend/settings/base.py``) +so the harness runs against a dev stack with zero flags, and every value is +overridable by env or CLI for staging/load hosts. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class DbConfig: + """Read-only Postgres connection to the backend DB. + + The harness only ever SELECTs (latency readers + queue depth); it never + writes to these tables. ``schema`` is set on the search_path at connect time + so the PG-queue tables (which live in the ``unstract`` schema) resolve + unqualified. + """ + + host: str = "localhost" + port: int = 5432 + name: str = "unstract_db" + user: str = "unstract_dev" + password: str = "unstract_pass" + schema: str = "unstract" + + @classmethod + def from_env(cls) -> DbConfig: + # Read defaults off a default instance, not the class: on a slots + # dataclass ``cls.port`` is the slot descriptor, not the default value. + d = cls() + return cls( + host=os.environ.get("DB_HOST", d.host), + port=int(os.environ.get("DB_PORT", d.port)), + name=os.environ.get("DB_NAME", d.name), + user=os.environ.get("DB_USER", d.user), + password=os.environ.get("DB_PASSWORD", d.password), + schema=os.environ.get("DB_SCHEMA", d.schema), + ) + + def dsn_kwargs(self) -> dict[str, object]: + return { + "host": self.host, + "port": self.port, + "dbname": self.name, + "user": self.user, + "password": self.password, + "options": f"-c search_path={self.schema},public", + } diff --git a/pg_benchmark/pg_benchmark/db.py b/pg_benchmark/pg_benchmark/db.py new file mode 100644 index 0000000000..55f36b1296 --- /dev/null +++ b/pg_benchmark/pg_benchmark/db.py @@ -0,0 +1,210 @@ +"""Read-only latency readers over the backend DB. + +Why DB-side and not just client wall-clock: the server-measured +``execution_time`` is the truest cross-transport number (it excludes the +harness's own HTTP/poll overhead), and per-file ``execution_time`` exposes +*parallelism* — the single most important PG-vs-Celery signal for fan-out work. + +Transport is classified post-hoc from columns that survive on the execution row +even after the (ephemeral) queue message is deleted: + +- ``queue_message_id IS NOT NULL`` → PG transport +- ``task_id IS NOT NULL`` → Celery transport +- neither → inline / synchronous (no async dispatch) + +Deliberately NOT read here: ``pg_queue_message.enqueued_at`` / ``vt`` and +``pg_task_result`` — those rows are deleted on ack / swept on expiry, so +enqueue→pickup latency is only observable by *live sampling* during a run (see +``sampler`` — a later slice), never post-hoc. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field + +import psycopg2 + +from .config import DbConfig + + +class Transport(enum.Enum): + """Which transport carried an execution, inferred from persistent columns.""" + + PG = "pg" + CELERY = "celery" + INLINE = "inline" + + @classmethod + def classify(cls, *, has_queue_message_id: bool, has_task_id: bool) -> Transport: + if has_queue_message_id: + return cls.PG + if has_task_id: + return cls.CELERY + return cls.INLINE + + +@dataclass(frozen=True, slots=True) +class ExecutionLatency: + """One execution's measured latencies (server-side, persistent).""" + + execution_id: str + transport: Transport + status: str + total_files: int + server_execution_time: float | None + file_times: list[float] = field(default_factory=list) + + @property + def is_terminal(self) -> bool: + return self.status in ("COMPLETED", "ERROR", "STOPPED") + + @property + def parallelism(self) -> float | None: + """Effective parallelism = sum(file_times) / server_execution_time. + + ``≈ N`` means all N files overlapped (ideal fan-out); ``≈ 1`` means they + ran serially. ``None`` when it can't be computed (no files / no timing). + """ + if not self.file_times or not self.server_execution_time: + return None + return sum(self.file_times) / self.server_execution_time + + +def connect(config: DbConfig) -> psycopg2.extensions.connection: + """Open a read-only-intent connection (autocommit; we only SELECT).""" + conn = psycopg2.connect(**config.dsn_kwargs()) + conn.autocommit = True + return conn + + +_RECENT_SQL = """ +SELECT + e.id::text, + (e.queue_message_id IS NOT NULL) AS has_qmid, + (e.task_id IS NOT NULL) AS has_taskid, + e.status, + e.total_files, + e.execution_time, + COALESCE( + ARRAY( + SELECT f.execution_time + FROM workflow_file_execution f + WHERE f.workflow_execution_id = e.id + AND f.execution_time IS NOT NULL + ORDER BY f.created_at + ), + ARRAY[]::double precision[] + ) AS file_times +FROM workflow_execution e +{where} +ORDER BY e.created_at DESC +LIMIT %(limit)s +""" + + +def fetch_recent( + conn: psycopg2.extensions.connection, + *, + limit: int = 100, + transport: Transport | None = None, + terminal_only: bool = True, +) -> list[ExecutionLatency]: + """Return the most recent executions as ``ExecutionLatency`` records. + + ``transport`` filters to one transport (server-side); ``terminal_only`` + restricts to finished runs so ``execution_time`` is populated. + """ + clauses: list[str] = [] + params: dict[str, object] = {"limit": limit} + if terminal_only: + clauses.append("e.status IN ('COMPLETED', 'ERROR', 'STOPPED')") + if transport is Transport.PG: + clauses.append("e.queue_message_id IS NOT NULL") + elif transport is Transport.CELERY: + clauses.append("e.queue_message_id IS NULL AND e.task_id IS NOT NULL") + elif transport is Transport.INLINE: + clauses.append("e.queue_message_id IS NULL AND e.task_id IS NULL") + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + sql = _RECENT_SQL.format(where=where) + + rows: list[ExecutionLatency] = [] + with conn.cursor() as cur: + cur.execute(sql, params) + for ( + execution_id, + has_qmid, + has_taskid, + status, + total_files, + exec_time, + file_times, + ) in cur: + rows.append( + ExecutionLatency( + execution_id=execution_id, + transport=Transport.classify( + has_queue_message_id=has_qmid, has_task_id=has_taskid + ), + status=status, + total_files=total_files or 0, + server_execution_time=exec_time, + file_times=list(file_times or []), + ) + ) + return rows + + +_TERMINAL_STATUSES = ("COMPLETED", "ERROR", "STOPPED") + +_ONE_SQL = _RECENT_SQL.format(where="WHERE e.id = %(execution_id)s::uuid") + + +def fetch_status(conn: psycopg2.extensions.connection, execution_id: str) -> str | None: + """Return an execution's current status, or ``None`` if the row is absent. + + Used by the load probe to poll for terminality straight from the DB (no auth, + cheaper than the REST status endpoint). + """ + with conn.cursor() as cur: + cur.execute( + "SELECT status FROM workflow_execution WHERE id = %s::uuid", + (execution_id,), + ) + row = cur.fetchone() + return row[0] if row else None + + +def fetch_one( + conn: psycopg2.extensions.connection, execution_id: str +) -> ExecutionLatency | None: + """Return the full ``ExecutionLatency`` for one execution, or ``None``.""" + with conn.cursor() as cur: + cur.execute(_ONE_SQL, {"execution_id": execution_id, "limit": 1}) + row = cur.fetchone() + if row is None: + return None + execution_id_, has_qmid, has_taskid, status, total_files, exec_time, file_times = row + return ExecutionLatency( + execution_id=execution_id_, + transport=Transport.classify( + has_queue_message_id=has_qmid, has_task_id=has_taskid + ), + status=status, + total_files=total_files or 0, + server_execution_time=exec_time, + file_times=list(file_times or []), + ) + + +def is_terminal_status(status: str | None) -> bool: + return status in _TERMINAL_STATUSES + + +def queue_depth(conn: psycopg2.extensions.connection) -> dict[str, int]: + """Current live ``pg_queue_message`` count per queue (load-monitoring aid).""" + with conn.cursor() as cur: + cur.execute( + "SELECT queue_name, count(*) FROM pg_queue_message GROUP BY queue_name" + ) + return {name: count for name, count in cur} diff --git a/pg_benchmark/pg_benchmark/probe.py b/pg_benchmark/pg_benchmark/probe.py new file mode 100644 index 0000000000..0e5ade192a --- /dev/null +++ b/pg_benchmark/pg_benchmark/probe.py @@ -0,0 +1,121 @@ +"""One full execution probe: trigger → poll to terminal → read server latency. + +Combines the two clocks the benchmark cares about: + +- **client wall-clock** (``http_latency``, ``wall_clock_e2e``) — what a caller + feels, including queue wait + dispatch + poll granularity +- **server-measured** (``server_execution_time``, ``parallelism``) — the + transport-fair number, read from the DB by ``execution_id`` + +``overhead = wall_clock_e2e - server_execution_time`` is the part the transport +is responsible for (admission + queue wait + result delivery); it is where a +polling vs push transport would diverge. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass + +import requests + +from .config import DbConfig +from .db import Transport, connect, fetch_one, fetch_status, is_terminal_status +from .trigger import TriggerConfig, trigger_execution + + +@dataclass(frozen=True, slots=True) +class RunResult: + """Everything measured for one execution probe.""" + + execution_id: str | None + transport: Transport | None + status: str | None + ok: bool + http_latency: float + wall_clock_e2e: float | None + server_execution_time: float | None + parallelism: float | None + overhead: float | None + error: str | None = None + + +def run_probe( + trigger_cfg: TriggerConfig, + db_cfg: DbConfig, + *, + poll_interval: float = 0.5, + timeout: float = 600.0, + session: requests.Session | None = None, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, +) -> RunResult: + """Trigger one execution and measure it end to end. + + ``clock``/``sleep`` are injected so the poll loop is unit-testable without + real time. Each probe owns its own DB connection (psycopg2 connections are + not safe to share across the load runner's threads). + """ + start = clock() + trig = trigger_execution(trigger_cfg, session) + if trig.execution_id is None: + return RunResult( + execution_id=None, + transport=None, + status=None, + ok=False, + http_latency=trig.http_latency, + wall_clock_e2e=None, + server_execution_time=None, + parallelism=None, + overhead=None, + error=trig.error or "trigger returned no execution_id", + ) + + conn = connect(db_cfg) + try: + deadline = start + timeout + status: str | None = None + while clock() < deadline: + status = fetch_status(conn, trig.execution_id) + if is_terminal_status(status): + break + sleep(poll_interval) + wall_clock_e2e = clock() - start + latency = fetch_one(conn, trig.execution_id) + finally: + conn.close() + + if not is_terminal_status(status): + return RunResult( + execution_id=trig.execution_id, + transport=latency.transport if latency else None, + status=status, + ok=False, + http_latency=trig.http_latency, + wall_clock_e2e=wall_clock_e2e, + server_execution_time=latency.server_execution_time if latency else None, + parallelism=latency.parallelism if latency else None, + overhead=None, + error=f"timed out after {timeout:.0f}s (last status={status})", + ) + + server_time = latency.server_execution_time if latency else None + overhead = ( + wall_clock_e2e - server_time + if server_time is not None and wall_clock_e2e is not None + else None + ) + return RunResult( + execution_id=trig.execution_id, + transport=latency.transport if latency else None, + status=status, + ok=status == "COMPLETED", + http_latency=trig.http_latency, + wall_clock_e2e=wall_clock_e2e, + server_execution_time=server_time, + parallelism=latency.parallelism if latency else None, + overhead=overhead, + error=None if status == "COMPLETED" else f"terminal status={status}", + ) diff --git a/pg_benchmark/pg_benchmark/report.py b/pg_benchmark/pg_benchmark/report.py new file mode 100644 index 0000000000..4264572698 --- /dev/null +++ b/pg_benchmark/pg_benchmark/report.py @@ -0,0 +1,156 @@ +"""Aggregate executions by transport and render a PG-vs-Celery comparison. + +The comparison is the point: same metric, side by side, per transport, so a +regression or win is visible at a glance rather than buried in two separate runs. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .db import ExecutionLatency, Transport +from .probe import RunResult +from .runner import LoadOutcome +from .stats import Summary, summarize + + +@dataclass(frozen=True, slots=True) +class TransportReport: + """Per-transport rollup of a sample of executions.""" + + transport: Transport + count: int + execution_time: Summary + parallelism: Summary + error_rate: float + + +def build_reports(executions: list[ExecutionLatency]) -> list[TransportReport]: + """Group by transport and summarise execution-time + parallelism + errors.""" + reports: list[TransportReport] = [] + for transport in Transport: + bucket = [e for e in executions if e.transport is transport] + if not bucket: + continue + exec_times = [ + e.server_execution_time for e in bucket if e.server_execution_time is not None + ] + parallelisms = [e.parallelism for e in bucket if e.parallelism is not None] + errors = sum(1 for e in bucket if e.status == "ERROR") + reports.append( + TransportReport( + transport=transport, + count=len(bucket), + execution_time=summarize(exec_times), + parallelism=summarize(parallelisms), + error_rate=errors / len(bucket), + ) + ) + return reports + + +@dataclass(frozen=True, slots=True) +class LoadReport: + """Per-transport rollup of a controlled load run.""" + + transport: Transport + triggered: int + completed: int + failed: int + wall_clock_e2e: Summary + server_execution_time: Summary + overhead: Summary + http_latency: Summary + + +def build_load_reports(results: list[RunResult]) -> list[LoadReport]: + """Group probe results by observed transport and summarise each metric.""" + reports: list[LoadReport] = [] + # ``None`` transport bucket = runs that never produced a classifiable row. + transports: list[Transport | None] = [*Transport, None] + for transport in transports: + bucket = [r for r in results if r.transport is transport] + if not bucket: + continue + completed = [r for r in bucket if r.ok] + reports.append( + LoadReport( + transport=transport or Transport.INLINE, + triggered=len(bucket), + completed=len(completed), + failed=len(bucket) - len(completed), + wall_clock_e2e=summarize( + [r.wall_clock_e2e for r in bucket if r.wall_clock_e2e is not None] + ), + server_execution_time=summarize( + [ + r.server_execution_time + for r in bucket + if r.server_execution_time is not None + ] + ), + overhead=summarize( + [r.overhead for r in bucket if r.overhead is not None] + ), + http_latency=summarize([r.http_latency for r in bucket]), + ) + ) + return reports + + +def render_load(outcome: LoadOutcome) -> str: + """Render a load run: throughput headline + per-transport latency tables.""" + reports = build_load_reports(outcome.results) + lines = [ + f"Load run: {len(outcome.results)} triggered, " + f"{len(outcome.completed)} completed in {outcome.wall_clock:.1f}s " + f"→ {outcome.throughput:.2f} completed/s", + "", + ] + if not reports: + lines.append("No results.") + return "\n".join(lines) + for r in reports: + lines.append( + f"── {r.transport.value.upper()} " + f"({r.completed}/{r.triggered} ok, {r.failed} failed) ".ljust(78, "─") + ) + lines.append(f" wall-clock e2e (s): {_fmt(r.wall_clock_e2e)}") + lines.append(f" server exec (s) : {_fmt(r.server_execution_time)}") + lines.append(f" overhead (s) : {_fmt(r.overhead)}") + lines.append(f" http trigger (s) : {_fmt(r.http_latency)}") + lines.append("") + return "\n".join(lines) + + +def _fmt(summary: Summary) -> str: + if summary.empty: + return " (no samples)" + return ( + f"n={summary.n:<4} mean={summary.mean:7.2f} p50={summary.p50:7.2f} " + f"p95={summary.p95:7.2f} p99={summary.p99:7.2f} max={summary.maximum:7.2f}" + ) + + +def render(reports: list[TransportReport]) -> str: + """Render reports as a human-readable text block.""" + if not reports: + return "No executions matched the query." + lines: list[str] = [] + for r in reports: + lines.append( + f"── {r.transport.value.upper()} ({r.count} executions, " + f"error_rate={r.error_rate:.1%}) ".ljust(78, "─") + ) + lines.append(f" execution_time (s): {_fmt(r.execution_time)}") + para = r.parallelism + if para.empty: + lines.append(" parallelism : (single-file or untimed)") + else: + lines.append( + f" parallelism (x) : mean={para.mean:5.2f} p50={para.p50:5.2f} " + f"min={para.minimum:5.2f} max={para.maximum:5.2f} " + f"(≈1 serial, ≈N fully parallel)" + ) + lines.append("") + return "\n".join(lines) diff --git a/pg_benchmark/pg_benchmark/runner.py b/pg_benchmark/pg_benchmark/runner.py new file mode 100644 index 0000000000..4348d5fa76 --- /dev/null +++ b/pg_benchmark/pg_benchmark/runner.py @@ -0,0 +1,76 @@ +"""Concurrent load runner: drive N probes at concurrency C. + +A fixed-size thread pool keeps exactly ``concurrency`` executions in flight at +once (the probes are I/O-bound — HTTP + DB polling — so threads are the right +tool). Each probe is independent and owns its own connections, so failures are +isolated: one bad run becomes one ``RunResult`` with ``ok=False``, never a dead +batch. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass + +from .config import DbConfig +from .probe import RunResult, run_probe +from .trigger import TriggerConfig + + +@dataclass(frozen=True, slots=True) +class LoadOutcome: + """The full result set of a load run plus its wall-clock + throughput.""" + + results: list[RunResult] + wall_clock: float # total seconds for the whole batch + + @property + def completed(self) -> list[RunResult]: + return [r for r in self.results if r.ok] + + @property + def throughput(self) -> float: + """Completed executions per second over the batch wall-clock.""" + if self.wall_clock <= 0: + return 0.0 + return len(self.completed) / self.wall_clock + + +def run_load( + trigger_cfg: TriggerConfig, + db_cfg: DbConfig, + *, + n: int, + concurrency: int, + poll_interval: float = 0.5, + timeout: float = 600.0, + on_result: Callable[[RunResult], None] | None = None, + probe: Callable[..., RunResult] = run_probe, +) -> LoadOutcome: + """Run ``n`` probes, at most ``concurrency`` at a time. + + ``on_result`` (optional) is called as each probe finishes — used by the CLI + to stream progress. ``probe`` is injectable so the runner is unit-testable + without HTTP/DB. + """ + results: list[RunResult] = [] + start = time.monotonic() + with ThreadPoolExecutor(max_workers=concurrency) as pool: + futures = [ + pool.submit( + probe, + trigger_cfg, + db_cfg, + poll_interval=poll_interval, + timeout=timeout, + ) + for _ in range(n) + ] + for future in as_completed(futures): + result = future.result() + results.append(result) + if on_result is not None: + on_result(result) + return LoadOutcome(results=results, wall_clock=time.monotonic() - start) diff --git a/pg_benchmark/pg_benchmark/stats.py b/pg_benchmark/pg_benchmark/stats.py new file mode 100644 index 0000000000..83e3cd190f --- /dev/null +++ b/pg_benchmark/pg_benchmark/stats.py @@ -0,0 +1,74 @@ +"""Pure latency statistics — no I/O, fully unit-testable. + +The benchmark's headline output is a distribution, not a single number: a mean +hides the tail, and the tail is exactly where a polling transport (PG +SKIP-LOCKED) is suspected to differ from a push transport (RabbitMQ). So every +metric is summarised as ``n / mean / p50 / p95 / p99 / min / max / stdev``. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Summary: + """A latency distribution summary over a sample of measurements.""" + + n: int + mean: float + p50: float + p95: float + p99: float + minimum: float + maximum: float + stdev: float + + @property + def empty(self) -> bool: + return self.n == 0 + + +def percentile(values: list[float], pct: float) -> float: + """Linear-interpolated percentile (``pct`` in 0..100). + + Uses the same "linear interpolation between closest ranks" method as + ``numpy.percentile`` / ``statistics.quantiles(method="inclusive")`` so the + numbers line up with what an analyst expects, without pulling numpy in. + """ + if not values: + raise ValueError("percentile() of an empty sample") + if pct <= 0: + return min(values) + if pct >= 100: + return max(values) + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + rank = (pct / 100.0) * (len(ordered) - 1) + low = math.floor(rank) + high = math.ceil(rank) + if low == high: + return ordered[low] + frac = rank - low + return ordered[low] * (1.0 - frac) + ordered[high] * frac + + +def summarize(values: list[float]) -> Summary: + """Summarise a sample. An empty sample yields an all-zero ``Summary``.""" + n = len(values) + if n == 0: + return Summary(0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + mean = sum(values) / n + variance = sum((v - mean) ** 2 for v in values) / n if n > 1 else 0.0 + return Summary( + n=n, + mean=mean, + p50=percentile(values, 50), + p95=percentile(values, 95), + p99=percentile(values, 99), + minimum=min(values), + maximum=max(values), + stdev=math.sqrt(variance), + ) diff --git a/pg_benchmark/pg_benchmark/trigger.py b/pg_benchmark/pg_benchmark/trigger.py new file mode 100644 index 0000000000..0fa19c8350 --- /dev/null +++ b/pg_benchmark/pg_benchmark/trigger.py @@ -0,0 +1,128 @@ +"""Trigger a workflow execution over HTTP against a running stack. + +Targets an Unstract **API deployment** (`POST .../execute/` with file uploads) — +the realistic load path that exercises the full dispatch → fan-out → executor +pipeline. The endpoint path and auth header are config so the same harness drives +local, staging, or any deployment without code changes. + +The response carries the ``execution_id`` (async deployments return it +immediately; sync ones return it alongside the finished status) — that id is the +join key the probe uses to read server-side latency from the DB. +""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass, field + +import requests + + +@dataclass(frozen=True, slots=True) +class TriggerConfig: + """How to POST one execution to an API deployment.""" + + base_url: str # e.g. http://localhost:8000 + path: str # e.g. /deployment/api/// + api_key: str + files: list[str] = field(default_factory=list) # local paths to upload + auth_header: str = "Authorization" + auth_prefix: str = "Bearer " + request_timeout: float = 600.0 + extra_fields: dict[str, str] = field(default_factory=dict) + + @property + def url(self) -> str: + return self.base_url.rstrip("/") + "/" + self.path.lstrip("/") + + @classmethod + def from_env(cls, **overrides: object) -> TriggerConfig: + base = { + "base_url": os.environ.get("PGBENCH_BASE_URL", "http://localhost:8000"), + "path": os.environ.get("PGBENCH_DEPLOY_PATH", ""), + "api_key": os.environ.get("PGBENCH_API_KEY", ""), + } + base.update({k: v for k, v in overrides.items() if v is not None}) + return cls(**base) # type: ignore[arg-type] + + +@dataclass(frozen=True, slots=True) +class TriggerResult: + """Outcome of a single trigger POST.""" + + execution_id: str | None + http_status: int + http_latency: float # seconds, request → response + error: str | None = None + + +def _extract_execution_id(payload: object) -> str | None: + """Pull the execution id out of common Unstract response shapes.""" + if not isinstance(payload, dict): + return None + for key in ("execution_id", "id"): + value = payload.get(key) + if isinstance(value, str) and value: + return value + # Some responses nest under "execution" or "data". + for key in ("execution", "data"): + nested = payload.get(key) + found = _extract_execution_id(nested) + if found: + return found + return None + + +def trigger_execution( + cfg: TriggerConfig, session: requests.Session | None = None +) -> TriggerResult: + """POST one execution; return the execution id + HTTP latency. + + Opens each upload file fresh (so the same config is reusable across many + concurrent triggers) and always closes them. + """ + session = session or requests.Session() + headers = {cfg.auth_header: f"{cfg.auth_prefix}{cfg.api_key}"} + handles = [open(path, "rb") for path in cfg.files] # noqa: SIM115 — closed below + try: + files = [ + ("files", (os.path.basename(path), fh, "application/octet-stream")) + for path, fh in zip(cfg.files, handles, strict=True) + ] + start = time.monotonic() + try: + resp = session.post( + cfg.url, + headers=headers, + files=files or None, + data=cfg.extra_fields or None, + timeout=cfg.request_timeout, + ) + except requests.RequestException as exc: + return TriggerResult( + execution_id=None, + http_status=0, + http_latency=time.monotonic() - start, + error=f"request failed: {exc}", + ) + latency = time.monotonic() - start + try: + payload = resp.json() + except ValueError: + payload = None + execution_id = _extract_execution_id(payload) + error = None + if resp.status_code >= 400: + error = f"HTTP {resp.status_code}: {resp.text[:200]}" + elif execution_id is None: + error = f"no execution_id in response: {str(payload)[:200]}" + return TriggerResult( + execution_id=execution_id, + http_status=resp.status_code, + http_latency=latency, + error=error, + ) + finally: + for fh in handles: + fh.close() diff --git a/pg_benchmark/pyproject.toml b/pg_benchmark/pyproject.toml new file mode 100644 index 0000000000..c4d66184a7 --- /dev/null +++ b/pg_benchmark/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "pg_benchmark" +version = "0.1.0" +description = "PG-queue vs Celery execution benchmark harness for Unstract" +requires-python = ">=3.10" +dependencies = ["psycopg2-binary>=2.9", "requests>=2.31"] + +[tool.pytest.ini_options] +# Root pytest here so it does not inherit the backend's Django pytest config. +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 90 diff --git a/pg_benchmark/requirements.txt b/pg_benchmark/requirements.txt new file mode 100644 index 0000000000..e8be2e4ce9 --- /dev/null +++ b/pg_benchmark/requirements.txt @@ -0,0 +1,5 @@ +# Runtime deps for the PG-vs-Celery benchmark harness. +# Kept minimal + stdlib-first; psycopg2 for DB readers, requests for triggering. +psycopg2-binary>=2.9 +requests>=2.31 +# test-only: pytest diff --git a/pg_benchmark/tests/test_load.py b/pg_benchmark/tests/test_load.py new file mode 100644 index 0000000000..f87680c916 --- /dev/null +++ b/pg_benchmark/tests/test_load.py @@ -0,0 +1,212 @@ +"""Unit tests for the load layer (probe / runner / load report) with fakes. + +No HTTP or DB: ``run_probe``'s collaborators (trigger + DB readers) are +monkeypatched, and the poll loop's clock/sleep are injected, so the timing logic +is exercised deterministically. +""" + +from __future__ import annotations + +import pytest + +import pg_benchmark.probe as probe_mod +from pg_benchmark.config import DbConfig +from pg_benchmark.db import ExecutionLatency, Transport +from pg_benchmark.probe import RunResult, run_probe +from pg_benchmark.report import build_load_reports, render_load +from pg_benchmark.runner import LoadOutcome, run_load +from pg_benchmark.trigger import TriggerConfig, TriggerResult + + +class FakeClock: + """Returns successive values, repeating the last once exhausted.""" + + def __init__(self, values): + self._values = list(values) + self._i = 0 + + def __call__(self): + v = self._values[min(self._i, len(self._values) - 1)] + self._i += 1 + return v + + +def _trigger_cfg(): + return TriggerConfig(base_url="http://x", path="/p/", api_key="k") + + +def _db_cfg(): + return DbConfig() + + +def _latency(transport=Transport.PG, status="COMPLETED", exec_time=30.0, files=(29.0,)): + return ExecutionLatency( + execution_id="e1", + transport=transport, + status=status, + total_files=len(files), + server_execution_time=exec_time, + file_times=list(files), + ) + + +def _patch_probe(monkeypatch, *, trigger, statuses, latency): + monkeypatch.setattr(probe_mod, "trigger_execution", lambda cfg, session=None: trigger) + monkeypatch.setattr(probe_mod, "connect", lambda cfg: object()) + status_iter = iter(statuses) + monkeypatch.setattr( + probe_mod, "fetch_status", lambda conn, eid: next(status_iter, statuses[-1]) + ) + monkeypatch.setattr(probe_mod, "fetch_one", lambda conn, eid: latency) + # connect() returns a bare object(); ensure .close() is a no-op + monkeypatch.setattr( + probe_mod, "connect", lambda cfg: type("C", (), {"close": lambda s: None})() + ) + + +class TestRunProbe: + def test_success_computes_overhead(self, monkeypatch): + trig = TriggerResult(execution_id="e1", http_status=200, http_latency=0.05) + _patch_probe( + monkeypatch, + trigger=trig, + statuses=["EXECUTING", "COMPLETED"], + latency=_latency(exec_time=30.0), + ) + clock = FakeClock([100.0, 100.1, 100.2, 130.6]) # wall = 30.6s + result = run_probe( + _trigger_cfg(), + _db_cfg(), + poll_interval=0.0, + clock=clock, + sleep=lambda s: None, + ) + assert result.ok + assert result.transport is Transport.PG + assert result.wall_clock_e2e == pytest.approx(30.6) + assert result.server_execution_time == 30.0 + assert result.overhead == pytest.approx(0.6) + assert result.http_latency == 0.05 + + def test_trigger_without_execution_id_is_failure(self, monkeypatch): + trig = TriggerResult( + execution_id=None, http_status=401, http_latency=0.02, error="HTTP 401" + ) + _patch_probe(monkeypatch, trigger=trig, statuses=["x"], latency=None) + result = run_probe(_trigger_cfg(), _db_cfg(), clock=FakeClock([1.0])) + assert not result.ok + assert result.execution_id is None + assert "401" in result.error + + def test_timeout_when_never_terminal(self, monkeypatch): + trig = TriggerResult(execution_id="e1", http_status=200, http_latency=0.05) + _patch_probe( + monkeypatch, + trigger=trig, + statuses=["EXECUTING"], + latency=_latency(status="EXECUTING", exec_time=None, files=()), + ) + # deadline = start + timeout = 100 + 1 = 101; clock crosses it immediately. + clock = FakeClock([100.0, 102.0, 102.0]) + result = run_probe( + _trigger_cfg(), + _db_cfg(), + timeout=1.0, + clock=clock, + sleep=lambda s: None, + ) + assert not result.ok + assert "timed out" in result.error + assert result.overhead is None + + def test_error_status_is_not_ok(self, monkeypatch): + trig = TriggerResult(execution_id="e1", http_status=200, http_latency=0.05) + _patch_probe( + monkeypatch, + trigger=trig, + statuses=["ERROR"], + latency=_latency(status="ERROR"), + ) + clock = FakeClock([100.0, 100.1, 130.0]) + result = run_probe( + _trigger_cfg(), + _db_cfg(), + clock=clock, + sleep=lambda s: None, + ) + assert not result.ok + assert result.status == "ERROR" + + +class TestRunner: + def _result(self, ok=True, transport=Transport.PG, wall=10.0): + return RunResult( + execution_id="e", + transport=transport, + status="COMPLETED" if ok else "ERROR", + ok=ok, + http_latency=0.05, + wall_clock_e2e=wall, + server_execution_time=9.0, + parallelism=1.8, + overhead=wall - 9.0, + error=None if ok else "err", + ) + + def test_run_load_collects_all_and_computes_throughput(self): + calls = {"n": 0} + + def fake_probe(tcfg, dcfg, **kw): + calls["n"] += 1 + return self._result(ok=calls["n"] % 2 == 1) + + outcome = run_load( + _trigger_cfg(), + _db_cfg(), + n=4, + concurrency=2, + probe=fake_probe, + ) + assert len(outcome.results) == 4 + assert len(outcome.completed) == 2 + assert outcome.throughput >= 0.0 + + def test_load_outcome_zero_walltime_is_safe(self): + outcome = LoadOutcome(results=[self._result()], wall_clock=0.0) + assert outcome.throughput == 0.0 + + +class TestLoadReport: + def _result(self, transport, ok=True, wall=10.0, server=9.0): + return RunResult( + execution_id="e", + transport=transport, + status="COMPLETED" if ok else "ERROR", + ok=ok, + http_latency=0.05, + wall_clock_e2e=wall, + server_execution_time=server, + parallelism=1.8, + overhead=wall - server, + error=None, + ) + + def test_groups_by_transport(self): + results = [ + self._result(Transport.PG), + self._result(Transport.PG, ok=False), + self._result(Transport.CELERY), + ] + reports = build_load_reports(results) + by_t = {r.transport: r for r in reports} + assert by_t[Transport.PG].triggered == 2 + assert by_t[Transport.PG].completed == 1 + assert by_t[Transport.PG].failed == 1 + assert by_t[Transport.CELERY].triggered == 1 + + def test_render_load_smoke(self): + outcome = LoadOutcome(results=[self._result(Transport.PG)], wall_clock=10.0) + text = render_load(outcome) + assert "Load run" in text + assert "PG" in text + assert "wall-clock e2e" in text diff --git a/pg_benchmark/tests/test_stats.py b/pg_benchmark/tests/test_stats.py new file mode 100644 index 0000000000..d8f62811c1 --- /dev/null +++ b/pg_benchmark/tests/test_stats.py @@ -0,0 +1,113 @@ +"""Unit tests for the pure statistics + transport-classification layer.""" + +from __future__ import annotations + +import math + +import pytest + +from pg_benchmark.db import ExecutionLatency, Transport +from pg_benchmark.stats import percentile, summarize + + +class TestPercentile: + def test_endpoints_are_min_and_max(self): + values = [1.0, 2.0, 3.0, 4.0, 5.0] + assert percentile(values, 0) == 1.0 + assert percentile(values, 100) == 5.0 + + def test_median_of_odd_sample(self): + assert percentile([3.0, 1.0, 2.0], 50) == 2.0 + + def test_median_of_even_sample_interpolates(self): + assert percentile([1.0, 2.0, 3.0, 4.0], 50) == 2.5 + + def test_p95_interpolates_between_ranks(self): + # 0..100 step 1 → p95 lands exactly on 95 (rank 95 of 100). + values = [float(i) for i in range(101)] + assert percentile(values, 95) == pytest.approx(95.0) + + def test_single_element(self): + assert percentile([7.0], 50) == 7.0 + assert percentile([7.0], 99) == 7.0 + + def test_empty_raises(self): + with pytest.raises(ValueError): + percentile([], 50) + + +class TestSummarize: + def test_empty_sample_is_all_zero(self): + s = summarize([]) + assert s.empty + assert s.n == 0 and s.mean == 0.0 and s.p99 == 0.0 + + def test_basic_summary(self): + s = summarize([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]) + assert s.n == 8 + assert s.mean == pytest.approx(5.0) + assert s.minimum == 2.0 and s.maximum == 9.0 + assert s.stdev == pytest.approx(2.0) # population stdev of this classic sample + + def test_single_value_has_zero_stdev(self): + s = summarize([3.3]) + assert s.n == 1 and s.stdev == 0.0 and s.p50 == 3.3 + + +class TestTransportClassify: + def test_pg_wins_when_queue_message_present(self): + assert ( + Transport.classify(has_queue_message_id=True, has_task_id=True) + is Transport.PG + ) + + def test_celery_when_only_task_id(self): + assert ( + Transport.classify(has_queue_message_id=False, has_task_id=True) + is Transport.CELERY + ) + + def test_inline_when_neither(self): + assert ( + Transport.classify(has_queue_message_id=False, has_task_id=False) + is Transport.INLINE + ) + + +class TestParallelism: + def _exec(self, *, exec_time, file_times): + return ExecutionLatency( + execution_id="x", + transport=Transport.PG, + status="COMPLETED", + total_files=len(file_times), + server_execution_time=exec_time, + file_times=file_times, + ) + + def test_fully_parallel_two_files_is_near_two(self): + # Two 30s files that overlapped → execution ~34s → ratio ≈ 1.76. + e = self._exec(exec_time=34.0, file_times=[29.9, 29.8]) + assert e.parallelism == pytest.approx(59.7 / 34.0) + assert e.parallelism > 1.5 + + def test_serial_two_files_is_near_one(self): + e = self._exec(exec_time=60.0, file_times=[30.0, 30.0]) + assert e.parallelism == pytest.approx(1.0) + + def test_none_when_no_files(self): + assert self._exec(exec_time=10.0, file_times=[]).parallelism is None + + def test_none_when_no_exec_time(self): + e = self._exec(exec_time=None, file_times=[1.0]) + assert e.parallelism is None + + def test_is_terminal(self): + assert self._exec(exec_time=1.0, file_times=[1.0]).is_terminal + + +def test_stdev_matches_math(): + values = [1.0, 2.0, 3.0] + s = summarize(values) + expected = math.sqrt(((1 - 2) ** 2 + 0 + (3 - 2) ** 2) / 3) + assert s.stdev == pytest.approx(expected) From bc33c8c83492533f5dddfca0934e3f6733fb1534 Mon Sep 17 00:00:00 2001 From: ali Date: Mon, 22 Jun 2026 17:06:36 +0530 Subject: [PATCH 35/65] =?UTF-8?q?UN-3445=20[TOOL]=20pg=5Fbenchmark=20?= =?UTF-8?q?=E2=80=94=20parse=20real=20API-deployment=20response=20+=20extr?= =?UTF-8?q?a=20headers/form=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated against a live API deployment: the execute response nests the execution_id inside message.status_api as a ?execution_id= query param (not a top-level field). Fix _extract_execution_id to handle it (+ status_api/message wrappers), and add --header KEY:VALUE / --form KEY=VALUE so the harness can send the subscription headers + tags/timeout fields a real deployment expects. One real execution confirmed end-to-end: PG transport, COMPLETED, 24.08s server time / 19.9s file — measurement path verified. test_trigger.py pins the response shape + the header/form parsing. Co-Authored-By: Claude Opus 4.8 --- pg_benchmark/pg_benchmark/cli.py | 25 +++++++++++++ pg_benchmark/pg_benchmark/trigger.py | 36 ++++++++++++++---- pg_benchmark/tests/test_trigger.py | 56 ++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 pg_benchmark/tests/test_trigger.py diff --git a/pg_benchmark/pg_benchmark/cli.py b/pg_benchmark/pg_benchmark/cli.py index 08757b6cc1..6fb18231de 100644 --- a/pg_benchmark/pg_benchmark/cli.py +++ b/pg_benchmark/pg_benchmark/cli.py @@ -71,6 +71,17 @@ def _cmd_queue_depth(args: argparse.Namespace) -> int: return 0 +def _parse_kv(items: list[str], *, sep: str, label: str) -> dict[str, str]: + """Parse repeated ``KEYVALUE`` CLI args into a dict (split on first sep only).""" + out: dict[str, str] = {} + for item in items: + key, found, value = item.partition(sep) + if not found: + raise SystemExit(f"error: {label} expects KEY{sep}VALUE, got {item!r}") + out[key.strip()] = value.strip() + return out + + def _cmd_run(args: argparse.Namespace) -> int: api_key = args.api_key or os.environ.get("PGBENCH_API_KEY", "") if not args.path: @@ -79,6 +90,8 @@ def _cmd_run(args: argparse.Namespace) -> int: if not api_key: print("error: --api-key or PGBENCH_API_KEY is required", file=sys.stderr) return 2 + extra_headers = _parse_kv(args.header or [], sep=":", label="--header") + extra_fields = _parse_kv(args.form or [], sep="=", label="--form") trigger_cfg = TriggerConfig( base_url=args.base_url, path=args.path, @@ -86,6 +99,8 @@ def _cmd_run(args: argparse.Namespace) -> int: files=args.file or [], auth_header=args.auth_header, auth_prefix=args.auth_prefix, + extra_headers=extra_headers, + extra_fields=extra_fields, ) db_cfg = _db_config(args) done = {"n": 0} @@ -158,6 +173,16 @@ def build_parser() -> argparse.ArgumentParser: ) run.add_argument("--api-key", help="API key (or set PGBENCH_API_KEY)") run.add_argument("--file", action="append", help="local file to upload (repeatable)") + run.add_argument( + "--header", + action="append", + help="extra request header KEY:VALUE (repeatable), e.g. X-subscription-id:sub-1", + ) + run.add_argument( + "--form", + action="append", + help="extra multipart form field KEY=VALUE (repeatable), e.g. tags=ali1", + ) run.add_argument("--auth-header", default="Authorization") run.add_argument("--auth-prefix", default="Bearer ") run.add_argument("--poll-interval", type=float, default=0.5) diff --git a/pg_benchmark/pg_benchmark/trigger.py b/pg_benchmark/pg_benchmark/trigger.py index 0fa19c8350..fc9f320913 100644 --- a/pg_benchmark/pg_benchmark/trigger.py +++ b/pg_benchmark/pg_benchmark/trigger.py @@ -15,6 +15,7 @@ import os import time from dataclasses import dataclass, field +from urllib.parse import parse_qs, urlparse import requests @@ -30,7 +31,8 @@ class TriggerConfig: auth_header: str = "Authorization" auth_prefix: str = "Bearer " request_timeout: float = 600.0 - extra_fields: dict[str, str] = field(default_factory=dict) + extra_fields: dict[str, str] = field(default_factory=dict) # multipart form fields + extra_headers: dict[str, str] = field(default_factory=dict) # e.g. X-subscription-id @property def url(self) -> str: @@ -57,18 +59,38 @@ class TriggerResult: error: str | None = None +def _id_from_url(url: str) -> str | None: + """Extract the ``execution_id`` query param from a status URL, if present.""" + try: + values = parse_qs(urlparse(url).query).get("execution_id") + except ValueError: + return None + return values[0] if values else None + + def _extract_execution_id(payload: object) -> str | None: - """Pull the execution id out of common Unstract response shapes.""" + """Pull the execution id out of Unstract API-deployment response shapes. + + The async/sync execute response nests the id inside ``message.status_api`` as + a ``?execution_id=...`` query param (not a top-level field), so probe the + direct keys, then ``status_api``, then recurse into the common wrappers. + """ + if isinstance(payload, str): + return _id_from_url(payload) if not isinstance(payload, dict): return None for key in ("execution_id", "id"): value = payload.get(key) if isinstance(value, str) and value: return value - # Some responses nest under "execution" or "data". - for key in ("execution", "data"): - nested = payload.get(key) - found = _extract_execution_id(nested) + status_api = payload.get("status_api") + if isinstance(status_api, str): + found = _id_from_url(status_api) + if found: + return found + # Real shape wraps everything under "message"; older shapes used execution/data. + for key in ("message", "execution", "data"): + found = _extract_execution_id(payload.get(key)) if found: return found return None @@ -83,7 +105,7 @@ def trigger_execution( concurrent triggers) and always closes them. """ session = session or requests.Session() - headers = {cfg.auth_header: f"{cfg.auth_prefix}{cfg.api_key}"} + headers = {cfg.auth_header: f"{cfg.auth_prefix}{cfg.api_key}", **cfg.extra_headers} handles = [open(path, "rb") for path in cfg.files] # noqa: SIM115 — closed below try: files = [ diff --git a/pg_benchmark/tests/test_trigger.py b/pg_benchmark/tests/test_trigger.py new file mode 100644 index 0000000000..46e5bc21e7 --- /dev/null +++ b/pg_benchmark/tests/test_trigger.py @@ -0,0 +1,56 @@ +"""Unit tests for trigger response parsing — pins the real Unstract response shape.""" + +from __future__ import annotations + +from pg_benchmark.cli import _parse_kv +from pg_benchmark.trigger import _extract_execution_id, _id_from_url + +EID = "b1f16024-45f2-4e39-8756-d40e24148e30" + + +class TestExtractExecutionId: + def test_real_api_deployment_shape(self): + # The exact shape returned by POST /deployment/api/// — id is a + # query param inside message.status_api, not a top-level field. + payload = { + "message": { + "execution_status": "COMPLETED", + "status_api": f"/deployment/api/org_X/api-test/?execution_id={EID}", + "error": None, + "result": [{"file": "dec-2025.pdf"}], + } + } + assert _extract_execution_id(payload) == EID + + def test_top_level_execution_id(self): + assert _extract_execution_id({"execution_id": EID}) == EID + + def test_nested_under_data(self): + assert _extract_execution_id({"data": {"id": EID}}) == EID + + def test_none_when_absent(self): + assert _extract_execution_id({"message": {"status": "ok"}}) is None + assert _extract_execution_id("not-json") is None + + def test_id_from_url(self): + assert _id_from_url(f"/x/?execution_id={EID}&foo=1") == EID + assert _id_from_url("/x/?foo=1") is None + + +class TestParseKv: + def test_header_split_on_first_colon(self): + # A header value can itself contain a colon (e.g. a URL). + out = _parse_kv(["X-Sub:sub-1", "X-Url:http://x:8000"], sep=":", label="--header") + assert out == {"X-Sub": "sub-1", "X-Url": "http://x:8000"} + + def test_form_split_on_equals(self): + assert _parse_kv(["tags=ali1", "timeout=300"], sep="=", label="--form") == { + "tags": "ali1", + "timeout": "300", + } + + def test_missing_separator_raises(self): + import pytest + + with pytest.raises(SystemExit): + _parse_kv(["bad"], sep=":", label="--header") From d9b2e95d9b372acb3e2161099b4cb1196d6ebce8 Mon Sep 17 00:00:00 2001 From: ali Date: Mon, 22 Jun 2026 17:41:16 +0530 Subject: [PATCH 36/65] =?UTF-8?q?UN-3445=20[TOOL]=20pg=5Fbenchmark=20?= =?UTF-8?q?=E2=80=94=20mock=20OpenAI=20server=20for=20zero-cost=20load=20t?= =?UTF-8?q?esting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instant OpenAI-compatible mock LLM+embedding responder (stdlib http.server, threaded, dependency-free). Point an OpenAI-compatible adapter's api_base at it + pair with Unstract's built-in noOpX2text / noOpVectorDb adapters → a full execution runs the whole queue -> fan-out -> executor path at $0, so the transport (not the LLM) is the measured variable. python -m pg_benchmark.mock_server --port 8901 Serves /v1/chat/completions, /v1/embeddings (batch), /v1/models, /health; --content / --embedding-dim / --latency-ms knobs. 6 tests over real HTTP on an ephemeral port. README documents the zero-cost load-test setup. Co-Authored-By: Claude Opus 4.8 --- pg_benchmark/README.md | 39 ++++ pg_benchmark/pg_benchmark/mock_server.py | 223 +++++++++++++++++++++++ pg_benchmark/tests/test_mock_server.py | 84 +++++++++ 3 files changed, 346 insertions(+) create mode 100644 pg_benchmark/pg_benchmark/mock_server.py create mode 100644 pg_benchmark/tests/test_mock_server.py diff --git a/pg_benchmark/README.md b/pg_benchmark/README.md index 6ba438dd1a..8b4286239b 100644 --- a/pg_benchmark/README.md +++ b/pg_benchmark/README.md @@ -59,6 +59,45 @@ DB connection defaults mirror the local docker-compose stack (`localhost:5432/unstract_db`, schema `unstract`). Override via `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` env vars or the `--db-*` flags. +### Triggering a real deployment + +`run` POSTs to an API deployment. The execute response nests the `execution_id` +inside `message.status_api` (a `?execution_id=` query param) — the harness parses +that automatically. Subscription headers + form fields the deployment expects are +passed through: + +```bash +python -m pg_benchmark run --n 1 --concurrency 1 \ + --path /deployment/api/// --api-key \ + --file /path/to/doc.pdf \ + --header X-subscription-id:sub-1 --form tags=bench --form timeout=300 +``` + +## Zero-cost load testing (mock adapters) + +Real LLM/extraction calls cost money **and** are a poor transport signal — a 20s +LLM call swamps the millisecond queue differences. For a true PG-vs-Celery +**transport** benchmark, mock the adapters so cost = $0 and execution time ≈ +queue + dispatch + fan-out: + +1. **LLM + embedding** → the bundled mock server (instant, OpenAI-compatible): + + ```bash + python -m pg_benchmark.mock_server --port 8901 # POST /v1/chat/completions + /v1/embeddings + ``` + + Then set an **OpenAI-compatible** LLM and embedding adapter's `api_base` to + `http://:8901/v1`. `--latency-ms` can simulate adapter latency; `--content` + sets the canned answer. + +2. **Extraction + vector DB** → Unstract's built-in **`noOpX2text`** and + **`noOpVectorDb`** adapters (instant, free, active by default). + +Wire a "benchmark" workflow to those four adapters, expose it as an API +deployment, then fire load through the **same** transport-comparison protocol: +run a batch with the flag/gate **off** (Celery) and another **on** (PG) against +the same deployment — the runner buckets each execution by its observed transport. + ## Tests ```bash diff --git a/pg_benchmark/pg_benchmark/mock_server.py b/pg_benchmark/pg_benchmark/mock_server.py new file mode 100644 index 0000000000..3531f96240 --- /dev/null +++ b/pg_benchmark/pg_benchmark/mock_server.py @@ -0,0 +1,223 @@ +"""Instant OpenAI-compatible mock server for zero-cost load testing. + +Stands in for the LLM + embedding adapters during a PG-vs-Celery transport +benchmark: point an OpenAI-compatible adapter's ``api_base`` at this server and +every chat/embedding call returns a canned response in ~milliseconds — so an +execution exercises the full queue → fan-out → executor path with **no real +inference cost**, and the transport (not the LLM) becomes the measured variable. +Pair with Unstract's built-in ``noOpX2text`` + ``noOpVectorDb`` adapters for a +fully free pipeline. + +Dependency-free (stdlib ``http.server``, threaded so concurrent executions don't +serialise) — runs anywhere the harness runs. NOT for production: no auth, canned +data only. + +Run: ``python -m pg_benchmark.mock_server --port 8901`` +Then set the adapter ``api_base`` to ``http://:8901/v1``. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Lock + + +@dataclass(frozen=True, slots=True) +class MockConfig: + """Canned-response knobs for the mock server.""" + + content: str = '{"result": "mock"}' # chat completion message content + embedding_dim: int = 1536 + latency_ms: float = 0.0 # artificial per-request delay (0 = instant) + + @property + def latency_s(self) -> float: + return self.latency_ms / 1000.0 + + +class _Counter: + """Thread-safe request tallies (so a load run can confirm the server was hit).""" + + def __init__(self) -> None: + self._lock = Lock() + self.chat = 0 + self.embeddings = 0 + + def bump(self, kind: str) -> int: + with self._lock: + value = getattr(self, kind) + 1 + setattr(self, kind, value) + return value + + +def chat_completion(model: str, content: str) -> dict: + return { + "id": "chatcmpl-mock", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + +def text_completion(model: str, content: str) -> dict: + return { + "id": "cmpl-mock", + "object": "text_completion", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "text": content, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + +def embeddings(model: str, count: int, dim: int) -> dict: + vector = [0.0] * dim + return { + "object": "list", + "data": [ + {"object": "embedding", "index": i, "embedding": vector} + for i in range(max(1, count)) + ], + "model": model, + "usage": {"prompt_tokens": 1, "total_tokens": 1}, + } + + +def _make_handler(config: MockConfig, counter: _Counter) -> type[BaseHTTPRequestHandler]: + class Handler(BaseHTTPRequestHandler): + # HTTP/1.1 + explicit Content-Length → keep-alive works (LiteLLM reuses + # connections under load). + protocol_version = "HTTP/1.1" + + def log_message(self, *args: object) -> None: # silence per-request logging + pass + + def _send_json(self, payload: dict, status: int = 200) -> None: + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_json(self) -> dict: + length = int(self.headers.get("Content-Length", 0) or 0) + raw = self.rfile.read(length) if length else b"" + try: + parsed = json.loads(raw or b"{}") + except ValueError: + return {} + return parsed if isinstance(parsed, dict) else {} + + def do_GET(self) -> None: # noqa: N802 — http.server API + path = self.path.rstrip("/") + if path.endswith("/models"): + self._send_json( + {"object": "list", "data": [{"id": "mock-model", "object": "model"}]} + ) + elif path.endswith("/health"): + self._send_json( + { + "status": "ok", + "chat": counter.chat, + "embeddings": counter.embeddings, + } + ) + else: + self._send_json({"error": "not found"}, status=404) + + def do_POST(self) -> None: # noqa: N802 — http.server API + if config.latency_s: + time.sleep(config.latency_s) + req = self._read_json() + model = req.get("model", "mock-model") + path = self.path + # Order matters: chat/completions must be matched before the bare + # /completions suffix. + if path.endswith("/chat/completions"): + counter.bump("chat") + self._send_json(chat_completion(model, config.content)) + elif path.endswith("/embeddings"): + counter.bump("embeddings") + inputs = req.get("input", "") + count = len(inputs) if isinstance(inputs, list) else 1 + self._send_json(embeddings(model, count, config.embedding_dim)) + elif path.endswith("/completions"): + counter.bump("chat") + self._send_json(text_completion(model, config.content)) + else: + self._send_json({"error": "not found"}, status=404) + + return Handler + + +def make_server( + config: MockConfig, host: str = "0.0.0.0", port: int = 8901 +) -> tuple[ThreadingHTTPServer, _Counter]: + """Build (but don't start) a threaded mock server. ``port=0`` picks a free port.""" + counter = _Counter() + server = ThreadingHTTPServer((host, port), _make_handler(config, counter)) + return server, counter + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="pg_benchmark.mock_server", + description="Instant OpenAI-compatible mock LLM+embedding server for load testing.", + ) + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8901) + parser.add_argument("--embedding-dim", type=int, default=1536) + parser.add_argument( + "--content", + default='{"result": "mock"}', + help="canned chat-completion message content", + ) + parser.add_argument( + "--latency-ms", + type=float, + default=0.0, + help="artificial per-request latency (0 = instant; use to simulate adapters)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + config = MockConfig( + content=args.content, + embedding_dim=args.embedding_dim, + latency_ms=args.latency_ms, + ) + server, _counter = make_server(config, host=args.host, port=args.port) + print( + f"mock OpenAI server on http://{args.host}:{args.port}/v1 " + f"(chat/completions + embeddings; dim={args.embedding_dim}, " + f"latency={args.latency_ms}ms) — Ctrl-C to stop", + flush=True, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nstopping mock server", flush=True) + finally: + server.shutdown() + server.server_close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pg_benchmark/tests/test_mock_server.py b/pg_benchmark/tests/test_mock_server.py new file mode 100644 index 0000000000..c680629c96 --- /dev/null +++ b/pg_benchmark/tests/test_mock_server.py @@ -0,0 +1,84 @@ +"""Unit tests for the mock OpenAI server — real HTTP on an ephemeral port.""" + +from __future__ import annotations + +import threading +from collections.abc import Iterator + +import pytest +import requests + +from pg_benchmark.mock_server import MockConfig, make_server + + +@pytest.fixture +def server() -> Iterator[tuple[str, object]]: + config = MockConfig(content='{"x": 1}', embedding_dim=8, latency_ms=0) + srv, counter = make_server(config, host="127.0.0.1", port=0) # 0 = free port + port = srv.server_address[1] + thread = threading.Thread(target=srv.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{port}", counter + finally: + srv.shutdown() + srv.server_close() + + +def test_chat_completion_shape(server): + base, counter = server + resp = requests.post( + f"{base}/v1/chat/completions", + json={"model": "m", "messages": [{"role": "user", "content": "hi"}]}, + timeout=5, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["object"] == "chat.completion" + assert body["choices"][0]["message"]["content"] == '{"x": 1}' + assert body["choices"][0]["finish_reason"] == "stop" + assert counter.chat == 1 + + +def test_embeddings_batch_returns_one_vector_per_input(server): + base, _ = server + resp = requests.post( + f"{base}/v1/embeddings", + json={"model": "m", "input": ["a", "b", "c"]}, + timeout=5, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["object"] == "list" + assert len(body["data"]) == 3 + assert len(body["data"][0]["embedding"]) == 8 # configured dim + + +def test_embeddings_single_string_input(server): + base, _ = server + resp = requests.post( + f"{base}/v1/embeddings", json={"model": "m", "input": "solo"}, timeout=5 + ) + assert len(resp.json()["data"]) == 1 + + +def test_path_without_v1_prefix_still_matches(server): + # LiteLLM may call /chat/completions directly if api_base already has no /v1. + base, _ = server + resp = requests.post( + f"{base}/chat/completions", json={"model": "m", "messages": []}, timeout=5 + ) + assert resp.status_code == 200 + assert resp.json()["object"] == "chat.completion" + + +def test_health_and_models(server): + base, _ = server + assert requests.get(f"{base}/health", timeout=5).json()["status"] == "ok" + models = requests.get(f"{base}/v1/models", timeout=5).json() + assert models["data"][0]["id"] == "mock-model" + + +def test_unknown_path_404(server): + base, _ = server + assert requests.post(f"{base}/v1/nonsense", json={}, timeout=5).status_code == 404 From 556e27e6860ba5296a40c2c7a2f423482a054e68 Mon Sep 17 00:00:00 2001 From: ali Date: Tue, 23 Jun 2026 16:15:54 +0530 Subject: [PATCH 37/65] =?UTF-8?q?UN-3445=20[TOOL]=20pg=5Fbenchmark=20?= =?UTF-8?q?=E2=80=94=20address=20SonarCloud=20findings=20(reflection=20+?= =?UTF-8?q?=20float-eq=20+=20http)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mock_server: respond with a fixed MOCK_MODEL instead of echoing the request's model field — do not reflect untrusted client input into the response body. - tests: route float comparisons through a math.isclose _close() helper instead of ==/pytest.approx (S1244: no equality checks on floats). - tests: https dummy URLs in fixtures (S5332 clear-text hotspot). Dev tooling; 40 tests green. Co-Authored-By: Claude Opus 4.8 --- pg_benchmark/pg_benchmark/mock_server.py | 14 ++++++--- pg_benchmark/tests/test_load.py | 20 ++++++++----- pg_benchmark/tests/test_stats.py | 37 ++++++++++++++---------- pg_benchmark/tests/test_trigger.py | 8 +++-- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/pg_benchmark/pg_benchmark/mock_server.py b/pg_benchmark/pg_benchmark/mock_server.py index 3531f96240..9b0936823c 100644 --- a/pg_benchmark/pg_benchmark/mock_server.py +++ b/pg_benchmark/pg_benchmark/mock_server.py @@ -40,6 +40,11 @@ def latency_s(self) -> float: return self.latency_ms / 1000.0 +# Fixed model name returned in every response. Deliberately NOT echoed from the +# request body — never reflect untrusted client input back into the response. +MOCK_MODEL = "mock-model" + + class _Counter: """Thread-safe request tallies (so a load run can confirm the server was hit).""" @@ -143,21 +148,22 @@ def do_POST(self) -> None: # noqa: N802 — http.server API if config.latency_s: time.sleep(config.latency_s) req = self._read_json() - model = req.get("model", "mock-model") + # Always respond with MOCK_MODEL — never echo the request's "model" + # (untrusted input) back into the response body. path = self.path # Order matters: chat/completions must be matched before the bare # /completions suffix. if path.endswith("/chat/completions"): counter.bump("chat") - self._send_json(chat_completion(model, config.content)) + self._send_json(chat_completion(MOCK_MODEL, config.content)) elif path.endswith("/embeddings"): counter.bump("embeddings") inputs = req.get("input", "") count = len(inputs) if isinstance(inputs, list) else 1 - self._send_json(embeddings(model, count, config.embedding_dim)) + self._send_json(embeddings(MOCK_MODEL, count, config.embedding_dim)) elif path.endswith("/completions"): counter.bump("chat") - self._send_json(text_completion(model, config.content)) + self._send_json(text_completion(MOCK_MODEL, config.content)) else: self._send_json({"error": "not found"}, status=404) diff --git a/pg_benchmark/tests/test_load.py b/pg_benchmark/tests/test_load.py index f87680c916..4d87054022 100644 --- a/pg_benchmark/tests/test_load.py +++ b/pg_benchmark/tests/test_load.py @@ -7,7 +7,8 @@ from __future__ import annotations -import pytest +import math + import pg_benchmark.probe as probe_mod from pg_benchmark.config import DbConfig @@ -18,6 +19,11 @@ from pg_benchmark.trigger import TriggerConfig, TriggerResult +def _close(a: float, b: float) -> bool: + """Float comparison helper — avoids ``==`` on floats (SonarCloud S1244).""" + return math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12) + + class FakeClock: """Returns successive values, repeating the last once exhausted.""" @@ -32,7 +38,7 @@ def __call__(self): def _trigger_cfg(): - return TriggerConfig(base_url="http://x", path="/p/", api_key="k") + return TriggerConfig(base_url="https://x", path="/p/", api_key="k") def _db_cfg(): @@ -83,10 +89,10 @@ def test_success_computes_overhead(self, monkeypatch): ) assert result.ok assert result.transport is Transport.PG - assert result.wall_clock_e2e == pytest.approx(30.6) - assert result.server_execution_time == 30.0 - assert result.overhead == pytest.approx(0.6) - assert result.http_latency == 0.05 + assert _close(result.wall_clock_e2e, 30.6) + assert _close(result.server_execution_time, 30.0) + assert _close(result.overhead, 0.6) + assert _close(result.http_latency, 0.05) def test_trigger_without_execution_id_is_failure(self, monkeypatch): trig = TriggerResult( @@ -173,7 +179,7 @@ def fake_probe(tcfg, dcfg, **kw): def test_load_outcome_zero_walltime_is_safe(self): outcome = LoadOutcome(results=[self._result()], wall_clock=0.0) - assert outcome.throughput == 0.0 + assert _close(outcome.throughput, 0.0) class TestLoadReport: diff --git a/pg_benchmark/tests/test_stats.py b/pg_benchmark/tests/test_stats.py index d8f62811c1..0fcfa778d5 100644 --- a/pg_benchmark/tests/test_stats.py +++ b/pg_benchmark/tests/test_stats.py @@ -10,26 +10,31 @@ from pg_benchmark.stats import percentile, summarize +def _close(a: float, b: float) -> bool: + """Float comparison helper — avoids ``==`` on floats (SonarCloud S1244).""" + return math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12) + + class TestPercentile: def test_endpoints_are_min_and_max(self): values = [1.0, 2.0, 3.0, 4.0, 5.0] - assert percentile(values, 0) == 1.0 - assert percentile(values, 100) == 5.0 + assert _close(percentile(values, 0), 1.0) + assert _close(percentile(values, 100), 5.0) def test_median_of_odd_sample(self): - assert percentile([3.0, 1.0, 2.0], 50) == 2.0 + assert _close(percentile([3.0, 1.0, 2.0], 50), 2.0) def test_median_of_even_sample_interpolates(self): - assert percentile([1.0, 2.0, 3.0, 4.0], 50) == 2.5 + assert _close(percentile([1.0, 2.0, 3.0, 4.0], 50), 2.5) def test_p95_interpolates_between_ranks(self): # 0..100 step 1 → p95 lands exactly on 95 (rank 95 of 100). values = [float(i) for i in range(101)] - assert percentile(values, 95) == pytest.approx(95.0) + assert _close(percentile(values, 95), 95.0) def test_single_element(self): - assert percentile([7.0], 50) == 7.0 - assert percentile([7.0], 99) == 7.0 + assert _close(percentile([7.0], 50), 7.0) + assert _close(percentile([7.0], 99), 7.0) def test_empty_raises(self): with pytest.raises(ValueError): @@ -40,18 +45,20 @@ class TestSummarize: def test_empty_sample_is_all_zero(self): s = summarize([]) assert s.empty - assert s.n == 0 and s.mean == 0.0 and s.p99 == 0.0 + assert s.n == 0 + assert _close(s.mean, 0.0) and _close(s.p99, 0.0) def test_basic_summary(self): s = summarize([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]) assert s.n == 8 - assert s.mean == pytest.approx(5.0) - assert s.minimum == 2.0 and s.maximum == 9.0 - assert s.stdev == pytest.approx(2.0) # population stdev of this classic sample + assert _close(s.mean, 5.0) + assert _close(s.minimum, 2.0) and _close(s.maximum, 9.0) + assert _close(s.stdev, 2.0) # population stdev of this classic sample def test_single_value_has_zero_stdev(self): s = summarize([3.3]) - assert s.n == 1 and s.stdev == 0.0 and s.p50 == 3.3 + assert s.n == 1 + assert _close(s.stdev, 0.0) and _close(s.p50, 3.3) class TestTransportClassify: @@ -88,12 +95,12 @@ def _exec(self, *, exec_time, file_times): def test_fully_parallel_two_files_is_near_two(self): # Two 30s files that overlapped → execution ~34s → ratio ≈ 1.76. e = self._exec(exec_time=34.0, file_times=[29.9, 29.8]) - assert e.parallelism == pytest.approx(59.7 / 34.0) + assert _close(e.parallelism, 59.7 / 34.0) assert e.parallelism > 1.5 def test_serial_two_files_is_near_one(self): e = self._exec(exec_time=60.0, file_times=[30.0, 30.0]) - assert e.parallelism == pytest.approx(1.0) + assert _close(e.parallelism, 1.0) def test_none_when_no_files(self): assert self._exec(exec_time=10.0, file_times=[]).parallelism is None @@ -110,4 +117,4 @@ def test_stdev_matches_math(): values = [1.0, 2.0, 3.0] s = summarize(values) expected = math.sqrt(((1 - 2) ** 2 + 0 + (3 - 2) ** 2) / 3) - assert s.stdev == pytest.approx(expected) + assert _close(s.stdev, expected) diff --git a/pg_benchmark/tests/test_trigger.py b/pg_benchmark/tests/test_trigger.py index 46e5bc21e7..d919d1fde5 100644 --- a/pg_benchmark/tests/test_trigger.py +++ b/pg_benchmark/tests/test_trigger.py @@ -39,9 +39,11 @@ def test_id_from_url(self): class TestParseKv: def test_header_split_on_first_colon(self): - # A header value can itself contain a colon (e.g. a URL). - out = _parse_kv(["X-Sub:sub-1", "X-Url:http://x:8000"], sep=":", label="--header") - assert out == {"X-Sub": "sub-1", "X-Url": "http://x:8000"} + # A header value can itself contain a colon (e.g. a URL with a port). + out = _parse_kv( + ["X-Sub:sub-1", "X-Url:https://x:8000"], sep=":", label="--header" + ) + assert out == {"X-Sub": "sub-1", "X-Url": "https://x:8000"} def test_form_split_on_equals(self): assert _parse_kv(["tags=ali1", "timeout=300"], sep="=", label="--form") == { From 4fee3638d17a0a65d7b50b5e00c8e8dfd4b9987d Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:01:40 +0530 Subject: [PATCH 38/65] =?UTF-8?q?UN-3616=20[FEAT]=20PG=20Queue=20=E2=80=94?= =?UTF-8?q?=20route=20API-triggered=20pipeline=20trigger=20through=20the?= =?UTF-8?q?=20transport=20flag=20(#2106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3616 [FEAT] PG Queue — route API-triggered pipeline trigger through the transport flag The API-triggered pipeline view dispatched scheduler.tasks.execute_pipeline_task on Celery unconditionally — so when the flag was on, the trigger stayed on Celery while the execution it spawns rode PG, breaking uniform flag control. The SCHEDULED path already routes this task to PG (pg_scheduler.dispatch_due_schedules → scheduler queue, run by worker-pg-scheduler); only the API-trigger view bypassed it. Route the trigger through resolve_transport (same flag as everything else) via a new dispatch_pipeline_trigger helper: PG → enqueue to the scheduler queue via pg_queue.producer.enqueue_task with the exact arg shape pg_scheduler sends; else Celery send_task (unchanged). Fail-closed — master gate off (prod default) → Celery → byte-identical to before, zero regression. Routing logic lives in the helper, not the view. 4 unit tests (PG/Celery routing, pipeline-id entity bucketing, identical args on both paths). Dev-tested against the live flag: gate ON → PG scheduler queue, gate OFF → Celery. Co-Authored-By: Claude Opus 4.8 * UN-3616 [REVIEW] address PR feedback on the pipeline-trigger dispatcher - Compare the WorkflowTransport enum member instead of its .value string, so a literal typo can't silently fall through to the Celery branch. - Coerce org_id to str at args[1] for consistency with the str(pipeline_id) / org_id kwarg coercion. - Add tests: PG enqueue failure propagates with NO Celery fallback (no double-dispatch); UUID pipeline_id is str-coerced in args while resolve_transport receives the raw UUID entity. - Docstring: lead with current behavior + correct the 'byte-identical args' claim (PG JSON-normalizes via enqueue_task). Trim the duplicated view comment. 6 unit tests green. Co-Authored-By: Claude Opus 4.8 * UN-3616 [REVIEW] address 2 SonarCloud findings on the dispatcher - S3516 (Blocker): dispatch_pipeline_trigger returned the same 'transport' var on both branches (invariant return the view ignored). Make it a None-returning side-effect (if/else, no return); tests assert on the dispatch (enqueue vs send_task) instead of the return. - Typing (High): widen org_id / pipeline_id to 'str | UUID' — what resolve_transport accepts and what the UUID test passes; they're str-coerced into the task args. 6 unit tests green; ruff clean. Co-Authored-By: Claude Opus 4.8 * UN-3616 [REVIEW] use shared is_pg_transport() helper (greptile) Replace the hand-rolled 'transport == WorkflowTransport.PG_QUEUE' check with unstract.core.data_models.is_pg_transport() — the single source for 'what counts as PG transport' — so this dispatcher doesn't open a second comparison site, consistent with workers/scheduler/tasks.py. 6 unit tests green; ruff clean. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../piepline_api_execution_views.py | 19 ++- backend/pipeline_v2/pipeline_dispatch.py | 75 ++++++++++++ backend/pipeline_v2/tests/__init__.py | 0 .../tests/test_pipeline_dispatch.py | 109 ++++++++++++++++++ 4 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 backend/pipeline_v2/pipeline_dispatch.py create mode 100644 backend/pipeline_v2/tests/__init__.py create mode 100644 backend/pipeline_v2/tests/test_pipeline_dispatch.py diff --git a/backend/pipeline_v2/piepline_api_execution_views.py b/backend/pipeline_v2/piepline_api_execution_views.py index a2b88a13df..ca0ba8fa35 100644 --- a/backend/pipeline_v2/piepline_api_execution_views.py +++ b/backend/pipeline_v2/piepline_api_execution_views.py @@ -8,6 +8,7 @@ from backend.celery_service import app as celery_app from pipeline_v2.deployment_helper import DeploymentHelper from pipeline_v2.models import Pipeline +from pipeline_v2.pipeline_dispatch import dispatch_pipeline_trigger logger = logging.getLogger(__name__) @@ -29,17 +30,13 @@ def initialize_request(self, request: Request, *args: Any, **kwargs: Any) -> Req def post( self, request: Request, org_name: str, pipeline_id: str, pipeline: Pipeline ) -> Response: - celery_app.send_task( - "scheduler.tasks.execute_pipeline_task", - args=[ - "", # workflow_id - org_name, # org_schema - "", # execution_action - "", # execution_id - pipeline_id, # pipepline_id - True, # with_logs - pipeline.pipeline_name, # name - ], + # Route through the transport flag instead of hardcoding Celery + # (see dispatch_pipeline_trigger). + dispatch_pipeline_trigger( + celery_app=celery_app, + org_id=org_name, + pipeline_id=pipeline_id, + pipeline_name=pipeline.pipeline_name, ) logger.info(f"Triggered {pipeline} by API") return Response({"message": f"Triggered {pipeline}"}, status=status.HTTP_200_OK) diff --git a/backend/pipeline_v2/pipeline_dispatch.py b/backend/pipeline_v2/pipeline_dispatch.py new file mode 100644 index 0000000000..c8d8002d09 --- /dev/null +++ b/backend/pipeline_v2/pipeline_dispatch.py @@ -0,0 +1,75 @@ +"""Transport-routed dispatch for the API-triggered pipeline execution trigger. + +Routes ``scheduler.tasks.execute_pipeline_task`` through the same +:func:`resolve_transport` flag as the rest of the execution path: the PG queue +when enabled for this pipeline/org, else Celery. **Fail-closed** — with the +master gate off (the production default) it resolves to Celery, behaving exactly +like the prior unconditional ``send_task`` (zero regression). The +scheduled-pipeline path already enqueues this task onto the PG ``scheduler`` +queue (``pg_scheduler.dispatch_due_schedules``, run by ``worker-pg-scheduler``); +this brings the API-trigger view to parity. + +The same ``args`` are sent on both paths; on PG they're JSON-normalized by +``enqueue_task`` (UUIDs/datetimes → str), a no-op for these string/bool values, +so the consumer sees the same payload. +""" + +from __future__ import annotations + +import logging +from typing import Any +from uuid import UUID + +from pg_queue.producer import enqueue_task +from workflow_manager.workflow_v2.transport import resolve_transport + +from unstract.core.data_models import is_pg_transport + +logger = logging.getLogger(__name__) + +# The fired task + the PG queue a ``scheduler`` consumer polls. Mirrors +# ``pg_scheduler.PIPELINE_TRIGGER_TASK`` / ``SCHEDULER_QUEUE_NAME`` — kept as local +# constants so the backend doesn't import the workers package. +PIPELINE_TRIGGER_TASK = "scheduler.tasks.execute_pipeline_task" +SCHEDULER_QUEUE = "scheduler" + + +def dispatch_pipeline_trigger( + *, + celery_app: Any, + org_id: str | UUID, + pipeline_id: str | UUID, + pipeline_name: str, +) -> None: + """Dispatch the pipeline-trigger task on the resolved transport. + + The positional args match ``execute_pipeline_task``'s signature on **both** + paths: ``(workflow_id, org_schema, execution_action, execution_id, + pipeline_id, with_logs, name)``. ``org_id`` / ``pipeline_id`` accept ``UUID`` + (what ``resolve_transport`` takes) and are str-coerced into the task args. + """ + args = ["", str(org_id), "", "", str(pipeline_id), True, pipeline_name] + # No execution exists yet (it's created inside the task), so the trigger + # buckets the flag by pipeline_id as the sticky entity. + transport = resolve_transport( + execution_id=pipeline_id, + organization_id=org_id, + pipeline_id=pipeline_id, + ) + # Use the shared is_pg_transport() — the single source for "what counts as PG + # transport" — rather than opening a second comparison site. + if is_pg_transport(transport): + msg_id = enqueue_task( + task_name=PIPELINE_TRIGGER_TASK, + queue=SCHEDULER_QUEUE, + args=args, + org_id=str(org_id or ""), + ) + logger.info( + "Pipeline %s trigger enqueued on PG scheduler queue (msg_id=%s)", + pipeline_id, + msg_id, + ) + else: + celery_app.send_task(PIPELINE_TRIGGER_TASK, args=args) + logger.info("Pipeline %s trigger dispatched on Celery", pipeline_id) diff --git a/backend/pipeline_v2/tests/__init__.py b/backend/pipeline_v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pipeline_v2/tests/test_pipeline_dispatch.py b/backend/pipeline_v2/tests/test_pipeline_dispatch.py new file mode 100644 index 0000000000..ac8bd63e0a --- /dev/null +++ b/backend/pipeline_v2/tests/test_pipeline_dispatch.py @@ -0,0 +1,109 @@ +"""Unit tests for the API-triggered pipeline-trigger transport routing (UN-3616). + +`resolve_transport` + `enqueue_task` are patched on the module, so no Flipt / DB +is needed — these pin the routing contract: PG when the flag resolves PG, Celery +otherwise (fail-closed), with byte-identical args on both paths. +""" + +from __future__ import annotations + +import uuid +from unittest.mock import MagicMock, patch + +import pytest + +import pipeline_v2.pipeline_dispatch as pd + +_EXPECTED_ARGS = ["", "org_x", "", "", "pid-1", True, "My Pipeline"] + + +def _dispatch(celery_app): + return pd.dispatch_pipeline_trigger( + celery_app=celery_app, + org_id="org_x", + pipeline_id="pid-1", + pipeline_name="My Pipeline", + ) + + +class TestDispatchPipelineTrigger: + def test_routes_to_pg_when_flag_resolves_pg(self): + celery = MagicMock() + with ( + patch.object(pd, "resolve_transport", return_value="pg_queue"), + patch.object(pd, "enqueue_task", return_value=42) as enqueue, + ): + _dispatch(celery) + enqueue.assert_called_once() + kwargs = enqueue.call_args.kwargs + assert kwargs["task_name"] == "scheduler.tasks.execute_pipeline_task" + assert kwargs["queue"] == "scheduler" + assert kwargs["args"] == _EXPECTED_ARGS + assert kwargs["org_id"] == "org_x" + celery.send_task.assert_not_called() + + def test_pg_enqueue_failure_propagates_with_no_celery_fallback(self): + # The dispatcher has no try/except → a PG enqueue failure must surface + # (no silent Celery fallback, which would risk a double-dispatch). + celery = MagicMock() + with ( + patch.object(pd, "resolve_transport", return_value="pg_queue"), + patch.object(pd, "enqueue_task", side_effect=RuntimeError("pg down")), + ): + with pytest.raises(RuntimeError, match="pg down"): + _dispatch(celery) + celery.send_task.assert_not_called() + + def test_uuid_pipeline_id_is_coerced_to_str_in_args(self): + # resolve_transport accepts a UUID, but the task args must carry strings. + pid = uuid.UUID("b1f16024-45f2-4e39-8756-d40e24148e30") + celery = MagicMock() + with ( + patch.object(pd, "resolve_transport", return_value="pg_queue") as resolve, + patch.object(pd, "enqueue_task", return_value=1) as enqueue, + ): + pd.dispatch_pipeline_trigger( + celery_app=celery, org_id="org_x", pipeline_id=pid, pipeline_name="P" + ) + assert enqueue.call_args.kwargs["args"][4] == str(pid) + # The raw UUID is passed to resolve_transport as the sticky entity. + assert resolve.call_args.kwargs["execution_id"] == pid + + def test_routes_to_celery_when_flag_resolves_celery(self): + celery = MagicMock() + with ( + patch.object(pd, "resolve_transport", return_value="celery"), + patch.object(pd, "enqueue_task") as enqueue, + ): + _dispatch(celery) + celery.send_task.assert_called_once_with( + "scheduler.tasks.execute_pipeline_task", args=_EXPECTED_ARGS + ) + enqueue.assert_not_called() + + def test_resolve_transport_called_with_pipeline_as_entity(self): + celery = MagicMock() + with ( + patch.object(pd, "resolve_transport", return_value="celery") as resolve, + patch.object(pd, "enqueue_task"), + ): + _dispatch(celery) + kwargs = resolve.call_args.kwargs + assert kwargs["execution_id"] == "pid-1" # buckets by pipeline_id + assert kwargs["organization_id"] == "org_x" + assert kwargs["pipeline_id"] == "pid-1" + + def test_args_identical_on_both_paths(self): + # The consumer must behave the same regardless of transport — assert the + # PG-path args equal the Celery-path args. + celery = MagicMock() + with patch.object(pd, "resolve_transport", return_value="celery"): + _dispatch(celery) + celery_args = celery.send_task.call_args.kwargs["args"] + celery2 = MagicMock() + with ( + patch.object(pd, "resolve_transport", return_value="pg_queue"), + patch.object(pd, "enqueue_task", return_value=1) as enqueue, + ): + _dispatch(celery2) + assert enqueue.call_args.kwargs["args"] == celery_args From edc78bb34702793672a381513a1b5de3bae5bf0a Mon Sep 17 00:00:00 2001 From: muhammad-ali-e <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:22:18 +0000 Subject: [PATCH 39/65] Commit uv.lock changes --- uv.lock | 2222 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 1120 insertions(+), 1102 deletions(-) diff --git a/uv.lock b/uv.lock index 1beb7a74de..8c1258f524 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 1 requires-python = "==3.12.*" [[package]] @@ -14,9 +14,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, upload-time = "2024-07-22T12:10:33.849Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/1e/6d5146676044247af566fa5843b335b1a647e6446070cec9c8b61c31b369/adlfs-2024.7.0.tar.gz", hash = "sha256:106995b91f0eb5e775bcd5957d180d9a14faef3271a063b1f65c66fd5ab05ddf", size = 48588 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/6f/51/a71c457bd0bc8af3e522b6999ff300852c7c446e384fd9904b0794f875df/adlfs-2024.7.0-py3-none-any.whl", hash = "sha256:2005c8e124fda3948f2a6abb2dbebb2c936d2d821acaca6afd61932edfa9bc07", size = 41349 }, ] [[package]] @@ -29,9 +29,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, upload-time = "2024-08-22T20:42:13.086Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/17/2f6305cc52976dea8156b56badc3602f162f86693a6cc8badc20d2c5cfe6/aiobotocore-2.13.3.tar.gz", hash = "sha256:ac5620f93cc3e7c2aef7c67ba2bb74035ff8d49ee2325821daed13b3dd82a473", size = 106736 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/81/98/1c1a1ba5d74f87cbabbf207d36a98551b082b8b187dc35f7808b43f4cf1a/aiobotocore-2.13.3-py3-none-any.whl", hash = "sha256:1272f765fd9414e1a68f8add71978367db94e17e36c3bf629cf1153eb5141fb9", size = 77194 }, ] [package.optional-dependencies] @@ -43,9 +43,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, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, ] [[package]] @@ -62,35 +62,35 @@ dependencies = [ { name = "typing-extensions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" }, - { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" }, - { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" }, - { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" }, - { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" }, - { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" }, - { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" }, - { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" }, - { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" }, - { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194 }, + { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966 }, + { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527 }, + { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420 }, + { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672 }, + { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064 }, + { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125 }, + { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112 }, + { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356 }, + { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119 }, + { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216 }, + { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500 }, + { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224 }, + { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252 }, + { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193 }, + { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650 }, + { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145 }, + { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250 }, ] [[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, upload-time = "2025-11-06T22:17:07.609Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182 }, ] [[package]] @@ -101,18 +101,18 @@ dependencies = [ { name = "frozenlist" }, { name = "typing-extensions" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, ] [[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, upload-time = "2025-12-23T19:25:43.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405 }, ] [[package]] @@ -122,27 +122,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, upload-time = "2024-11-12T19:55:44.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944 }, ] [[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, upload-time = "2025-11-10T22:07:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, ] [[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, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] [[package]] @@ -153,18 +153,18 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] [[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, upload-time = "2020-05-11T07:59:51.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, ] [[package]] @@ -174,9 +174,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argon2-cffi-bindings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657 }, ] [[package]] @@ -186,61 +186,61 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, - { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, - { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, - { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, - { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, - { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121 }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177 }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090 }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246 }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126 }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343 }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777 }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180 }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715 }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149 }, ] [[package]] name = "asgiref" version = "3.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345 }, ] [[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, upload-time = "2022-03-15T14:46:52.889Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045 }, ] [[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, upload-time = "2025-11-24T23:27:00.812Z" } +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, 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/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 }, ] [[package]] name = "attrs" version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, ] [[package]] @@ -251,9 +251,9 @@ dependencies = [ { name = "cryptography" }, { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548 }, ] [[package]] @@ -264,9 +264,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920 }, ] [[package]] @@ -278,9 +278,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, upload-time = "2023-05-10T21:17:05.665Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/ff/61369d06422b5ac48067215ff404841342651b14a89b46c8d8e1507c8f17/azure-datalake-store-0.0.53.tar.gz", hash = "sha256:05b6de62ee3f2a0a6e6941e6933b792b800c3e7f6ffce2fc324bc19875757393", size = 71430 } 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, upload-time = "2023-05-10T21:17:02.629Z" }, + { 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 }, ] [[package]] @@ -294,9 +294,9 @@ dependencies = [ { name = "msal-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304 } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138 }, ] [[package]] @@ -309,9 +309,9 @@ dependencies = [ { name = "isodate" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/25/fdcf1e381922dbab8ba23d6fd78d397fe6cbac6b480310218834b7bc91fe/azure_storage_blob-12.29.0.tar.gz", hash = "sha256:2824ddd7ebc9056034ebc76b17971a38e9aa5835abb0d565b9700493f2a6c657", size = 611359, upload-time = "2026-05-15T03:34:59.865Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/25/fdcf1e381922dbab8ba23d6fd78d397fe6cbac6b480310218834b7bc91fe/azure_storage_blob-12.29.0.tar.gz", hash = "sha256:2824ddd7ebc9056034ebc76b17971a38e9aa5835abb0d565b9700493f2a6c657", size = 611359 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2c/6ddee6a3e42d0236ba9259e4df7fa97fdc415ff0802b736c634baaf4b285/azure_storage_blob-12.29.0-py3-none-any.whl", hash = "sha256:ccf8a1bcd5e49df83ab85aab793b579e5ba2eeea2ad8900b2f62ca3a37dc391f", size = 434823, upload-time = "2026-05-15T03:35:01.837Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2c/6ddee6a3e42d0236ba9259e4df7fa97fdc415ff0802b736c634baaf4b285/azure_storage_blob-12.29.0-py3-none-any.whl", hash = "sha256:ccf8a1bcd5e49df83ab85aab793b579e5ba2eeea2ad8900b2f62ca3a37dc391f", size = 434823 }, ] [[package]] @@ -326,65 +326,65 @@ 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, upload-time = "2026-04-27T12:15:22.021Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/51/08fb68d23f4b0f6256fe85dc86e9576941550f890b079352fba719e07b39/banks-2.4.2.tar.gz", hash = "sha256:cda6013bd377ea7b701933578bfb9370fc21ad70bc13cedfc3f5cb2c034ca3dc", size = 188633 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/00/b6/8dc5477681b782e2f99de703e7a99828883364b9e03a60d3e2c47053d56a/banks-2.4.2-py3-none-any.whl", hash = "sha256:5fe407cc48c101f3e13d1cf732b83b8246003337612f13c0705d2e81f6faffb7", size = 35050 }, ] [[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, upload-time = "2025-09-25T19:50:47.829Z" } -wheels = [ - { 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" }, +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/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 }, ] [[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, upload-time = "2024-02-18T19:09:05.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764 }, ] [[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, upload-time = "2025-11-30T13:28:48.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070 }, ] [[package]] @@ -396,9 +396,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, upload-time = "2024-08-15T19:25:38.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/c1/f80cfbe564c89cdb080cd9ac2079ce05a2fac869bf8fbc45929ed3190da9/boto3-1.34.162.tar.gz", hash = "sha256:873f8f5d2f6f85f1018cbb0535b03cceddc7b655b61f66a0a56995238804f41f", size = 108585 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/65/41/faa5081761be3bac3999f912996c14c4dc9d06eab86c234bd6441f54bd64/boto3-1.34.162-py3-none-any.whl", hash = "sha256:d6f6096bdab35a0c0deff469563b87d184a28df7689790f7fe7be98502b7c590", size = 139174 }, ] [[package]] @@ -410,9 +410,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, upload-time = "2024-08-15T19:25:25.162Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/17d672eac6725da49bd5832e3bd2f74c4d212311cd393fd56b59f51a4e86/botocore-1.34.162.tar.gz", hash = "sha256:adc23be4fb99ad31961236342b7cbf3c0bfc62532cd02852196032e8c0d682f3", size = 12676693 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/bc/47/e35f788047c91110f48703a6254e5c84e33111b3291f7b57a653ca00accf/botocore-1.34.162-py3-none-any.whl", hash = "sha256:2d918b02db88d27a75b48275e6fb2506e9adaaddbec1ffa6a8a0898b34e769be", size = 12468049 }, ] [[package]] @@ -423,9 +423,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, upload-time = "2023-08-23T19:24:35.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/de/1c5e0faec600538f6a1d41c7ce7834cacddb2237923e30ddb225254b74b9/boxfs-0.2.1.tar.gz", hash = "sha256:c1889e12f53be3216b44f088237ac0f367a7a759a53b01b0c0edf2b3d694e50f", size = 9523 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/86/bb/243d10169c8397051bad6bdd10beb2407fa490bfe01216f5fad09e066191/boxfs-0.2.1-py3-none-any.whl", hash = "sha256:ae796c30309bd5a02654fff9eddf1ed320356225568fad0e109e1942beaef72a", size = 9358 }, ] [[package]] @@ -439,9 +439,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, upload-time = "2025-04-09T15:07:15.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/d7/c1a95bb602d7f90a85a68d8e6f11954e50c255110d39e2167c7796252622/boxsdk-3.14.0.tar.gz", hash = "sha256:7918b1929368724662474fffa417fa0457a523d089b8185260efbedd28c4f9b1", size = 232630 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/4d/5d/4e15511e0f4f2f9fbbf4646a8d0e138e5c53a3d428f1724e7dc3c8acf556/boxsdk-3.14.0-py2.py3-none-any.whl", hash = "sha256:0314e2f172b050e98489955f2e9001263de79c3dd751e6feee19f2195fdf7c01", size = 141329 }, ] [package.optional-dependencies] @@ -454,9 +454,9 @@ jwt = [ name = "cachetools" version = "5.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 }, ] [[package]] @@ -473,18 +473,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, upload-time = "2025-06-01T11:08:12.563Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775 }, ] [[package]] name = "certifi" version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, ] [[package]] @@ -494,63 +494,63 @@ 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, upload-time = "2025-09-08T23:24:04.541Z" } +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, 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/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 }, ] [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, ] [[package]] name = "chardet" version = "6.0.0.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/42/fb9436c103a881a377e34b9f58d77b5f503461c702ff654ebe86151bcfe9/chardet-6.0.0.post1.tar.gz", hash = "sha256:6b78048c3c97c7b2ed1fbad7a18f76f5a6547f7d34dbab536cc13887c9a92fa4", size = 12521798, upload-time = "2026-02-22T15:09:17.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/42/fb9436c103a881a377e34b9f58d77b5f503461c702ff654ebe86151bcfe9/chardet-6.0.0.post1.tar.gz", hash = "sha256:6b78048c3c97c7b2ed1fbad7a18f76f5a6547f7d34dbab536cc13887c9a92fa4", size = 12521798 } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/42/5de54f632c2de53cd3415b3703383d5fff43a94cbc0567ef362515261a21/chardet-6.0.0.post1-py3-none-any.whl", hash = "sha256:c894a36800549adf7bb5f2af47033281b75fdfcd2aa0f0243be0ad22a52e2dcb", size = 627245, upload-time = "2026-02-22T15:09:15.876Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/5de54f632c2de53cd3415b3703383d5fff43a94cbc0567ef362515261a21/chardet-6.0.0.post1-py3-none-any.whl", hash = "sha256:c894a36800549adf7bb5f2af47033281b75fdfcd2aa0f0243be0ad22a52e2dcb", size = 627245 }, ] [[package]] name = "charset-normalizer" version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, ] [[package]] @@ -560,9 +560,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147 }, ] [[package]] @@ -572,9 +572,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, upload-time = "2024-03-24T08:22:07.499Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631 }, ] [[package]] @@ -584,9 +584,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, upload-time = "2025-06-25T00:47:37.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343 } 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, upload-time = "2025-06-25T00:47:36.731Z" }, + { 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 }, ] [[package]] @@ -597,49 +597,61 @@ 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, upload-time = "2023-06-15T12:43:51.141Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289 }, ] [[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, upload-time = "2022-10-25T02:36:22.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] name = "coverage" version = "7.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489 } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, - { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, - { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, - { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, - { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, - { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967 }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329 }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839 }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576 }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690 }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949 }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242 }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608 }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753 }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823 }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323 }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197 }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515 }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324 }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944 }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764 }, ] [[package]] name = "cron-descriptor" version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/a0/455f5a0181cf9a0d2e84d3a66c88de019dce5644ad9680825d1c8a403335/cron_descriptor-1.4.0.tar.gz", hash = "sha256:b6ff4e3a988d7ca04a4ab150248e9f166fb7a5c828a85090e75bcc25aa93b4dd", size = 29922, upload-time = "2023-05-19T07:46:16.992Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/a0/455f5a0181cf9a0d2e84d3a66c88de019dce5644ad9680825d1c8a403335/cron_descriptor-1.4.0.tar.gz", hash = "sha256:b6ff4e3a988d7ca04a4ab150248e9f166fb7a5c828a85090e75bcc25aa93b4dd", size = 29922 } + +[[package]] +name = "croniter" +version = "6.2.2" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422 }, +] [[package]] name = "cryptography" @@ -648,36 +660,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587 }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433 }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620 }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283 }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573 }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677 }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808 }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941 }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579 }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326 }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672 }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574 }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868 }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107 }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482 }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266 }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228 }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097 }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582 }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479 }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481 }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713 }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165 }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947 }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059 }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575 }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117 }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100 }, ] [[package]] @@ -688,9 +700,9 @@ 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, upload-time = "2024-06-09T16:20:19.103Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 }, ] [[package]] @@ -701,27 +713,27 @@ dependencies = [ { name = "mbstrdecoder" }, { name = "typepy", extra = ["datetime"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/6f/a801320bb388d965be9c370ec753cc33120e6cbe0069fa05644f05821975/dataproperty-1.1.1.tar.gz", hash = "sha256:a83af82a234edda5378a36fb092bc90dd554646c5e58202a310acf468ae81bc8", size = 42954, upload-time = "2026-05-09T10:33:42.212Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/6f/a801320bb388d965be9c370ec753cc33120e6cbe0069fa05644f05821975/dataproperty-1.1.1.tar.gz", hash = "sha256:a83af82a234edda5378a36fb092bc90dd554646c5e58202a310acf468ae81bc8", size = 42954 } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/41/eab7fe313820578b341a2a1d6aeeedd2c38ec1e3f3d51e57e2735b5beac0/dataproperty-1.1.1-py3-none-any.whl", hash = "sha256:cf026aa002dbd6c57c619ec6741ffd61ae7bf2f20481951d8af2dff44480340e", size = 27691, upload-time = "2026-05-09T10:33:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/03/41/eab7fe313820578b341a2a1d6aeeedd2c38ec1e3f3d51e57e2735b5beac0/dataproperty-1.1.1-py3-none-any.whl", hash = "sha256:cf026aa002dbd6c57c619ec6741ffd61ae7bf2f20481951d8af2dff44480340e", size = 27691 }, ] [[package]] name = "decorator" version = "5.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084 } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365 }, ] [[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, upload-time = "2021-03-08T10:59:26.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, ] [[package]] @@ -731,36 +743,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523 } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298 }, ] [[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, upload-time = "2022-11-28T23:32:33.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/04/d24f6e645ad82ba0ef092fa17d9ef7a21953781663648a01c9371d9e8e98/dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd", size = 30782 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197 }, ] [[package]] name = "distlib" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, ] [[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, upload-time = "2023-12-24T09:54:32.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, ] [[package]] @@ -772,9 +784,9 @@ dependencies = [ { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/76/23ee9b9d2bd4119e930eb19164732b79c0a4f6259ca198209b0fe36551ea/Django-4.2.1.tar.gz", hash = "sha256:7efa6b1f781a6119a10ac94b4794ded90db8accbe7802281cd26f8664ffed59c", size = 10420051, upload-time = "2023-05-03T12:58:41.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/76/23ee9b9d2bd4119e930eb19164732b79c0a4f6259ca198209b0fe36551ea/Django-4.2.1.tar.gz", hash = "sha256:7efa6b1f781a6119a10ac94b4794ded90db8accbe7802281cd26f8664ffed59c", size = 10420051 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/13/78e8622180f101e95297965045ff1325ea7301c1b80f756debbeaa84c3be/Django-4.2.1-py3-none-any.whl", hash = "sha256:066b6debb5ac335458d2a713ed995570536c8b59a580005acb0732378d5eb1ee", size = 7988496, upload-time = "2023-05-03T12:58:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/12/13/78e8622180f101e95297965045ff1325ea7301c1b80f756debbeaa84c3be/Django-4.2.1-py3-none-any.whl", hash = "sha256:066b6debb5ac335458d2a713ed995570536c8b59a580005acb0732378d5eb1ee", size = 7988496 }, ] [[package]] @@ -789,9 +801,9 @@ dependencies = [ { name = "python-crontab" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/97/ca63898f76dd43fc91f4791b05dbbecb60dc99215f16b270e9b1e29af974/django-celery-beat-2.5.0.tar.gz", hash = "sha256:cd0a47f5958402f51ac0c715bc942ae33d7b50b4e48cba91bc3f2712be505df1", size = 159635, upload-time = "2023-03-14T10:02:10.9Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/97/ca63898f76dd43fc91f4791b05dbbecb60dc99215f16b270e9b1e29af974/django-celery-beat-2.5.0.tar.gz", hash = "sha256:cd0a47f5958402f51ac0c715bc942ae33d7b50b4e48cba91bc3f2712be505df1", size = 159635 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/92/fa53396870566276357bb81e3fece5b7f8a00f99c91689ff777c481d40e0/django_celery_beat-2.5.0-py3-none-any.whl", hash = "sha256:ae460faa5ea142fba0875409095d22f6bd7bcc7377889b85e8cab5c0dfb781fe", size = 97223, upload-time = "2023-03-14T10:02:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/c5/92/fa53396870566276357bb81e3fece5b7f8a00f99c91689ff777c481d40e0/django_celery_beat-2.5.0-py3-none-any.whl", hash = "sha256:ae460faa5ea142fba0875409095d22f6bd7bcc7377889b85e8cab5c0dfb781fe", size = 97223 }, ] [[package]] @@ -802,9 +814,9 @@ dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458 } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809 }, ] [[package]] @@ -815,9 +827,9 @@ dependencies = [ { name = "django" }, { name = "redis" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/9d/2272742fdd9d0a9f0b28cd995b0539430c9467a2192e4de2cea9ea6ad38c/django-redis-5.4.0.tar.gz", hash = "sha256:6a02abaa34b0fea8bf9b707d2c363ab6adc7409950b2db93602e6cb292818c42", size = 52567, upload-time = "2023-10-01T20:22:01.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/9d/2272742fdd9d0a9f0b28cd995b0539430c9467a2192e4de2cea9ea6ad38c/django-redis-5.4.0.tar.gz", hash = "sha256:6a02abaa34b0fea8bf9b707d2c363ab6adc7409950b2db93602e6cb292818c42", size = 52567 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/f1/63caad7c9222c26a62082f4f777de26389233b7574629996098bf6d25a4d/django_redis-5.4.0-py3-none-any.whl", hash = "sha256:ebc88df7da810732e2af9987f7f426c96204bf89319df4c6da6ca9a2942edd5b", size = 31119, upload-time = "2023-10-01T20:21:33.009Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f1/63caad7c9222c26a62082f4f777de26389233b7574629996098bf6d25a4d/django_redis-5.4.0-py3-none-any.whl", hash = "sha256:ebc88df7da810732e2af9987f7f426c96204bf89319df4c6da6ca9a2942edd5b", size = 31119 }, ] [[package]] @@ -827,7 +839,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/2a/da4db7649ac516fc4b89b86d697edb92362c4f6b0ab2d2fe20d1e0f6ab10/django-tenants-3.5.0.tar.gz", hash = "sha256:bed426108e1bd4f962afa38c1e0fd985a3e8c4c902ded60bd57dbf4fcc92d2cc", size = 117503, upload-time = "2023-05-11T14:10:26.045Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/2a/da4db7649ac516fc4b89b86d697edb92362c4f6b0ab2d2fe20d1e0f6ab10/django-tenants-3.5.0.tar.gz", hash = "sha256:bed426108e1bd4f962afa38c1e0fd985a3e8c4c902ded60bd57dbf4fcc92d2cc", size = 117503 } [[package]] name = "django-timezone-field" @@ -836,9 +848,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096, upload-time = "2025-12-06T23:50:44.591Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096 } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284, upload-time = "2025-12-06T23:50:43.302Z" }, + { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284 }, ] [[package]] @@ -849,9 +861,9 @@ dependencies = [ { name = "django" }, { name = "pytz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/53/5b2a002c5ebafd60dff1e1945a7d63dee40155830997439a9ba324f0fd50/djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", size = 1055343, upload-time = "2022-09-22T11:38:44.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/53/5b2a002c5ebafd60dff1e1945a7d63dee40155830997439a9ba324f0fd50/djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", size = 1055343 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/4b/3b46c0914ba4b7546a758c35fdfa8e7f017fcbe7f23c878239e93623337a/djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08", size = 1062761, upload-time = "2022-09-22T11:38:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4b/3b46c0914ba4b7546a758c35fdfa8e7f017fcbe7f23c878239e93623337a/djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08", size = 1062761 }, ] [[package]] @@ -865,18 +877,18 @@ 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, upload-time = "2023-06-01T14:24:49.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/73/f7c9a14e88e769f38cb7fb45aa88dfd795faa8e18aea11bababf6e068d5e/docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20", size = 259301 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/db/be/3032490fa33b36ddc8c4b1da3252c6f974e7133f1a50de00c6b85cca203a/docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9", size = 148096 }, ] [[package]] name = "docutils" version = "0.20.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365, upload-time = "2023-05-16T23:39:19.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365 } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666, upload-time = "2023-05-16T23:39:15.976Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666 }, ] [[package]] @@ -892,9 +904,9 @@ dependencies = [ { name = "pyyaml" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/e4/8f619b63bd8095f3797d41da186c707dd9add86b86341d1f350f1d15b2dd/drf-yasg-1.21.7.tar.gz", hash = "sha256:4c3b93068b3dfca6969ab111155e4dd6f7b2d680b98778de8fd460b7837bdb0d", size = 4512723, upload-time = "2023-07-20T13:47:34.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/e4/8f619b63bd8095f3797d41da186c707dd9add86b86341d1f350f1d15b2dd/drf-yasg-1.21.7.tar.gz", hash = "sha256:4c3b93068b3dfca6969ab111155e4dd6f7b2d680b98778de8fd460b7837bdb0d", size = 4512723 } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/a5/9fedcd955821ec3b4d26b8a723081eb0f400b7f0bc51f1f49136648423ff/drf_yasg-1.21.7-py3-none-any.whl", hash = "sha256:f85642072c35e684356475781b7ecf5d218fff2c6185c040664dd49f0a4be181", size = 4289125, upload-time = "2023-07-20T13:47:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/9fedcd955821ec3b4d26b8a723081eb0f400b7f0bc51f1f49136648423ff/drf_yasg-1.21.7-py3-none-any.whl", hash = "sha256:f85642072c35e684356475781b7ecf5d218fff2c6185c040664dd49f0a4be181", size = 4289125 }, ] [[package]] @@ -906,9 +918,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, upload-time = "2024-06-03T16:45:30.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/56/ac085f58e8e0d0bcafdf98c2605e454ac946e3d0c72679669ae112dc30be/dropbox-12.0.2.tar.gz", hash = "sha256:50057fd5ad5fcf047f542dfc6747a896e7ef982f1b5f8500daf51f3abd609962", size = 560236 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2d/de/95d8204d9a20fbdb353c5f8e4229b0fcb90f22b96f8246ff1f47c8a45fd5/dropbox-12.0.2-py3-none-any.whl", hash = "sha256:c5b7e9c2668adb6b12dcecd84342565dc50f7d35ab6a748d155cb79040979d1c", size = 572076 }, ] [[package]] @@ -920,15 +932,15 @@ 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, upload-time = "2024-05-27T14:04:37.648Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/15/6d8f4c3033ad2bc364b8bb613c52c96653f2268f32ecff4f3ab5f1d7c19b/dropboxdrivefs-1.4.1.tar.gz", hash = "sha256:6f3c6061d045813553ce91ed0e2b682f1d70bec74011943c92b3181faacefd34", size = 7413 } [[package]] name = "execnet" version = "2.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708 }, ] [[package]] @@ -940,83 +952,83 @@ dependencies = [ { name = "packaging" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c9/671f66f6b31ec48e5825d36435f0cb91189fa8bb6b50724029dbff4ca83c/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9064eb34f8f64438dd5b95c8f03a780b1a3f0b99c46eeacb1f0b5d15fc02dc1", size = 3452776, upload-time = "2025-12-24T10:27:01.419Z" }, - { url = "https://files.pythonhosted.org/packages/5a/4a/97150aa1582fb9c2bca95bd8fc37f27d3b470acec6f0a6833844b21e4b40/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_x86_64.whl", hash = "sha256:c8d097884521e1ecaea6467aeebbf1aa56ee4a36350b48b2ca6b39366565c317", size = 7896434, upload-time = "2025-12-24T10:27:03.592Z" }, - { url = "https://files.pythonhosted.org/packages/0b/d0/0940575f059591ca31b63a881058adb16a387020af1709dcb7669460115c/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ee330a284042c2480f2e90450a10378fd95655d62220159b1408f59ee83ebf1", size = 11485825, upload-time = "2025-12-24T10:27:05.681Z" }, - { url = "https://files.pythonhosted.org/packages/e7/e1/a5acac02aa593809f0123539afe7b4aff61d1db149e7093239888c9053e1/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab88ee287c25a119213153d033f7dd64c3ccec466ace267395872f554b648cd7", size = 23845772, upload-time = "2025-12-24T10:27:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/9c/7b/49dcaf354834ec457e85ca769d50bc9b5f3003fab7c94a9dcf08cf742793/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:85511129b34f890d19c98b82a0cd5ffb27d89d1cec2ee41d2621ee9f9ef8cf3f", size = 13477567, upload-time = "2025-12-24T10:27:10.822Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6b/12bb4037921c38bb2c0b4cfc213ca7e04bbbebbfea89b0b5746248ce446e/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b32eb4065bac352b52a9f5ae07223567fab0a976c7d05017c01c45a1c24264f", size = 25102239, upload-time = "2025-12-24T10:27:13.476Z" }, + { url = "https://files.pythonhosted.org/packages/07/c9/671f66f6b31ec48e5825d36435f0cb91189fa8bb6b50724029dbff4ca83c/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9064eb34f8f64438dd5b95c8f03a780b1a3f0b99c46eeacb1f0b5d15fc02dc1", size = 3452776 }, + { url = "https://files.pythonhosted.org/packages/5a/4a/97150aa1582fb9c2bca95bd8fc37f27d3b470acec6f0a6833844b21e4b40/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_x86_64.whl", hash = "sha256:c8d097884521e1ecaea6467aeebbf1aa56ee4a36350b48b2ca6b39366565c317", size = 7896434 }, + { url = "https://files.pythonhosted.org/packages/0b/d0/0940575f059591ca31b63a881058adb16a387020af1709dcb7669460115c/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ee330a284042c2480f2e90450a10378fd95655d62220159b1408f59ee83ebf1", size = 11485825 }, + { url = "https://files.pythonhosted.org/packages/e7/e1/a5acac02aa593809f0123539afe7b4aff61d1db149e7093239888c9053e1/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab88ee287c25a119213153d033f7dd64c3ccec466ace267395872f554b648cd7", size = 23845772 }, + { url = "https://files.pythonhosted.org/packages/9c/7b/49dcaf354834ec457e85ca769d50bc9b5f3003fab7c94a9dcf08cf742793/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:85511129b34f890d19c98b82a0cd5ffb27d89d1cec2ee41d2621ee9f9ef8cf3f", size = 13477567 }, + { url = "https://files.pythonhosted.org/packages/f7/6b/12bb4037921c38bb2c0b4cfc213ca7e04bbbebbfea89b0b5746248ce446e/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b32eb4065bac352b52a9f5ae07223567fab0a976c7d05017c01c45a1c24264f", size = 25102239 }, ] [[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, upload-time = "2025-10-19T22:19:22.402Z" } +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, 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/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 }, ] [[package]] name = "filelock" version = "3.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571 } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812 }, ] [[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, upload-time = "2022-11-02T17:34:04.141Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970 }, ] [[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, upload-time = "2025-10-06T05:38:17.865Z" } +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, 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/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, + { 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/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, ] [[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, upload-time = "2024-10-21T01:21:16.969Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/52/f16a068ebadae42526484c31f4398e62962504e5724a8ba5dc3409483df2/fsspec-2024.10.0.tar.gz", hash = "sha256:eda2d8a4116d4f2429db8550f2457da57279247dd930bb12f821b58391359493", size = 286853 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c6/b2/454d6e7f0158951d8a78c2e1eb4f69ae81beb8dca5fee9809c6c99e9d0d0/fsspec-2024.10.0-py3-none-any.whl", hash = "sha256:03b9a6785766a4de40368b88906366755e2819e758b83705c88cd7cb5fe81871", size = 179641 }, ] [package.optional-dependencies] @@ -1028,9 +1040,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, upload-time = "2023-03-28T06:22:46.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b8/c6081521ff70afdff55cd9512b2220bbf4fa88804dae51d1b57b4b58ef32/funcy-2.0.tar.gz", hash = "sha256:3963315d59d41c6f30c04bc910e10ab50a3ac4a225868bfa96feed133df075cb", size = 537931 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl", hash = "sha256:53df23c8bb1651b12f095df764bfb057935d49537a56de211b098f4c79614bb0", size = 30891 }, ] [[package]] @@ -1046,9 +1058,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, upload-time = "2024-10-21T13:43:26.163Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/1e/1d8c4593d9e2eb04918fec43253ab152823d67ad51ad9e3ab6b3a78c431a/gcsfs-2024.10.0.tar.gz", hash = "sha256:5df54cfe568e8fdeea5aafa7fed695cdc69a9a674e991ca8c1ce634f5df1d314", size = 79588 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/dc/96/d60e835fb7d10166c77aef0c1fa30e634153c03a0f486786977b95f88fde/gcsfs-2024.10.0-py2.py3-none-any.whl", hash = "sha256:bb2d23547e61203ea2dda5fa6c4b91a0c34b74ebe8bb6ab1926f6c33381bceb2", size = 34953 }, ] [[package]] @@ -1062,9 +1074,9 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001 } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274 }, ] [package.optional-dependencies] @@ -1084,9 +1096,9 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/f3/34ef8aca7909675fe327f96c1ed927f0520e7acf68af19157e96acc05e76/google_api_python_client-2.196.0.tar.gz", hash = "sha256:9f335d38f6caaa2747bcf64335ed1a9a19047d53e86538eda6a1b17d37f1743d", size = 14628129, upload-time = "2026-05-06T23:47:35.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/f3/34ef8aca7909675fe327f96c1ed927f0520e7acf68af19157e96acc05e76/google_api_python_client-2.196.0.tar.gz", hash = "sha256:9f335d38f6caaa2747bcf64335ed1a9a19047d53e86538eda6a1b17d37f1743d", size = 14628129 } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/c7/1817b4edf966d5afcac1c0781ca36d621bc0cb58104c4e7c2a475ab185f7/google_api_python_client-2.196.0-py3-none-any.whl", hash = "sha256:2591e9b47dcb17e4e62a09370aaee3bcf323af8f28ccecdabcd0a42a23ca4db5", size = 15206663, upload-time = "2026-05-06T23:47:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/1817b4edf966d5afcac1c0781ca36d621bc0cb58104c4e7c2a475ab185f7/google_api_python_client-2.196.0-py3-none-any.whl", hash = "sha256:2591e9b47dcb17e4e62a09370aaee3bcf323af8f28ccecdabcd0a42a23ca4db5", size = 15206663 }, ] [[package]] @@ -1100,9 +1112,9 @@ dependencies = [ { name = "six" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/e0/d2c96098280f17eb626d4da0b7e553b8e5648d57514c8cefec851c16920c/google-auth-2.20.0.tar.gz", hash = "sha256:030af34138909ccde0fbce611afc178f1d65d32fbff281f25738b1fe1c6f3eaa", size = 229669, upload-time = "2023-06-13T17:50:38.754Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/e0/d2c96098280f17eb626d4da0b7e553b8e5648d57514c8cefec851c16920c/google-auth-2.20.0.tar.gz", hash = "sha256:030af34138909ccde0fbce611afc178f1d65d32fbff281f25738b1fe1c6f3eaa", size = 229669 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/1a/5866a7c6e16abc1df395e6d2b9808984d0905c747d75f5e20f1a052421d1/google_auth-2.20.0-py2.py3-none-any.whl", hash = "sha256:23b7b0950fcda519bfb6692bf0d5289d2ea49fc143717cc7188458ec620e63fa", size = 181456, upload-time = "2023-06-13T17:50:36.408Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1a/5866a7c6e16abc1df395e6d2b9808984d0905c747d75f5e20f1a052421d1/google_auth-2.20.0-py2.py3-none-any.whl", hash = "sha256:23b7b0950fcda519bfb6692bf0d5289d2ea49fc143717cc7188458ec620e63fa", size = 181456 }, ] [[package]] @@ -1113,9 +1125,9 @@ dependencies = [ { name = "google-auth" }, { name = "httplib2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/f192c8bc7e41e0ebdbd95afcae4783417a34b6a6af62d22daf22c3fd38fc/google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35", size = 11161, upload-time = "2026-05-07T08:03:46.878Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/f192c8bc7e41e0ebdbd95afcae4783417a34b6a6af62d22daf22c3fd38fc/google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35", size = 11161 } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/be/954c35a62b9e31de66b0a43c225c9b6bb9e0f98d6b1dc110a2308e3644f5/google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91", size = 9529, upload-time = "2026-05-07T08:02:12.375Z" }, + { url = "https://files.pythonhosted.org/packages/97/be/954c35a62b9e31de66b0a43c225c9b6bb9e0f98d6b1dc110a2308e3644f5/google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91", size = 9529 }, ] [[package]] @@ -1126,9 +1138,9 @@ dependencies = [ { name = "google-auth" }, { name = "requests-oauthlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675, upload-time = "2026-05-07T08:03:47.816Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675 } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" }, + { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261 }, ] [[package]] @@ -1146,9 +1158,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, upload-time = "2023-07-19T23:12:12.7Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/ff/2c520952db184dec31e2ee988cfa37fa9e7776935a3f2eccc44252ecab5f/google-cloud-bigquery-3.11.4.tar.gz", hash = "sha256:697df117241a2283bcbb93b21e10badc14e51c9a90800d2a7e1a3e1c7d842974", size = 410777 } 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, upload-time = "2023-07-19T23:12:09.449Z" }, + { 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 }, ] [[package]] @@ -1159,9 +1171,9 @@ dependencies = [ { name = "google-api-core" }, { name = "google-auth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001 } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390 }, ] [[package]] @@ -1174,9 +1186,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, upload-time = "2023-03-27T14:51:09.684Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/6b/92b705f408c1d928526b65d1259be4254ef1f45e620f01f8665156b4d781/google-cloud-secret-manager-2.16.1.tar.gz", hash = "sha256:149d11ce9be7ea81d4ac3544d3fcd4c716a9edb2cb775d9c075231570b079fbb", size = 128884 } 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, upload-time = "2023-03-27T14:51:07.661Z" }, + { 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 }, ] [[package]] @@ -1190,22 +1202,22 @@ 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, upload-time = "2023-05-04T17:56:46.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/50/c9998f84fd8ce8799d7f8020466bbc5c9e3b1126b04a09fdb02378d451b0/google-cloud-storage-2.9.0.tar.gz", hash = "sha256:9b6ae7b509fc294bdacb84d0f3ea8e20e2c54a8b4bbe39c5707635fec214eff3", size = 5498811 } 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, upload-time = "2023-05-04T17:56:43.929Z" }, + { 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 }, ] [[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, upload-time = "2025-12-16T00:35:25.142Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192 } 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, 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/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 }, ] [[package]] @@ -1215,9 +1227,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-crc32c" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/4b/0b235beccc310d0a48adbc7246b719d173cca6c88c572dfa4b090e39143c/google_resumable_media-2.9.0.tar.gz", hash = "sha256:f7cfb224846a9dd444d125115dfbe8ef02a2b893e78f087762fe716a255a734b", size = 2164534, upload-time = "2026-05-07T08:04:44.236Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/4b/0b235beccc310d0a48adbc7246b719d173cca6c88c572dfa4b090e39143c/google_resumable_media-2.9.0.tar.gz", hash = "sha256:f7cfb224846a9dd444d125115dfbe8ef02a2b893e78f087762fe716a255a734b", size = 2164534 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/73/3518e63deb1667c5409a4579e28daf5e84479a87a72c547e0487f7883dcd/google_resumable_media-2.9.0-py3-none-any.whl", hash = "sha256:c8901e88e389af8bed64d9696c74d8bad961865eb2236e13e0bfca9bb0a65ca3", size = 81507, upload-time = "2026-05-07T08:03:23.809Z" }, + { url = "https://files.pythonhosted.org/packages/07/73/3518e63deb1667c5409a4579e28daf5e84479a87a72c547e0487f7883dcd/google_resumable_media-2.9.0-py3-none-any.whl", hash = "sha256:c8901e88e389af8bed64d9696c74d8bad961865eb2236e13e0bfca9bb0a65ca3", size = 81507 }, ] [[package]] @@ -1227,9 +1239,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631 }, ] [package.optional-dependencies] @@ -1241,18 +1253,18 @@ grpc = [ name = "greenlet" version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, - { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, - { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228 }, + { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775 }, + { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436 }, + { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610 }, + { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388 }, + { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775 }, + { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768 }, + { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983 }, + { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840 }, + { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615 }, ] [[package]] @@ -1263,9 +1275,9 @@ dependencies = [ { name = "griffecli" }, { name = "griffelib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/49/eb6d2935e27883af92c930ed40cc4c69bcd32c402be43b8ca4ab20510f67/griffe-2.0.2.tar.gz", hash = "sha256:c5d56326d159f274492e9bf93a9895cec101155d944caa66d0fc4e0c13751b92", size = 293757, upload-time = "2026-03-27T11:34:52.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/49/eb6d2935e27883af92c930ed40cc4c69bcd32c402be43b8ca4ab20510f67/griffe-2.0.2.tar.gz", hash = "sha256:c5d56326d159f274492e9bf93a9895cec101155d944caa66d0fc4e0c13751b92", size = 293757 } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/c0/2bb018eecf9a83c68db9cd9fffd9dab25f102ad30ed869451046e46d1187/griffe-2.0.2-py3-none-any.whl", hash = "sha256:2b31816460aee1996af26050a1fc6927a2e5936486856707f55508e4c9b5960b", size = 5141, upload-time = "2026-03-27T11:34:47.721Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/2bb018eecf9a83c68db9cd9fffd9dab25f102ad30ed869451046e46d1187/griffe-2.0.2-py3-none-any.whl", hash = "sha256:2b31816460aee1996af26050a1fc6927a2e5936486856707f55508e4c9b5960b", size = 5141 }, ] [[package]] @@ -1276,18 +1288,18 @@ dependencies = [ { name = "colorama" }, { name = "griffelib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/e0/6a7d661d71bb043656a109b91d84a42b5342752542074ec83b16a6eb97f0/griffecli-2.0.2.tar.gz", hash = "sha256:40a1ad4181fc39685d025e119ae2c5b669acdc1f19b705fb9bf971f4e6f6dffb", size = 56281, upload-time = "2026-03-27T11:34:50.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/e0/6a7d661d71bb043656a109b91d84a42b5342752542074ec83b16a6eb97f0/griffecli-2.0.2.tar.gz", hash = "sha256:40a1ad4181fc39685d025e119ae2c5b669acdc1f19b705fb9bf971f4e6f6dffb", size = 56281 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e8/90d93356c88ac34c20cb5edffca68138df55ca9bbd1a06eccfbcec8fdbe5/griffecli-2.0.2-py3-none-any.whl", hash = "sha256:0d44d39e59afa81e288a3e1c3bf352cc4fa537483326ac06b8bb6a51fd8303a0", size = 9500, upload-time = "2026-03-27T11:34:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e8/90d93356c88ac34c20cb5edffca68138df55ca9bbd1a06eccfbcec8fdbe5/griffecli-2.0.2-py3-none-any.whl", hash = "sha256:0d44d39e59afa81e288a3e1c3bf352cc4fa537483326ac06b8bb6a51fd8303a0", size = 9500 }, ] [[package]] name = "griffelib" version = "2.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461 } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357 }, ] [[package]] @@ -1299,9 +1311,9 @@ dependencies = [ { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706 } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" }, + { url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675 }, ] [[package]] @@ -1311,18 +1323,18 @@ 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, upload-time = "2026-02-06T09:57:18.093Z" } +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, 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/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 }, ] [[package]] @@ -1334,9 +1346,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, upload-time = "2024-08-06T00:37:08.003Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/d7/013ef01c5a1c2fd0932c27c904934162f69f41ca0f28396d3ffe4d386123/grpcio-status-1.62.3.tar.gz", hash = "sha256:289bdd7b2459794a12cf95dc0cb727bd4a1742c37bd823f760236c937e53a485", size = 13063 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/90/40/972271de05f9315c0d69f9f7ebbcadd83bc85322f538637d11bb8c67803d/grpcio_status-1.62.3-py3-none-any.whl", hash = "sha256:f9049b762ba8de6b1086789d8315846e094edac2c50beaf462338b301a8fd4b8", size = 14448 }, ] [[package]] @@ -1348,25 +1360,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, upload-time = "2024-08-06T00:37:11.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fa/b69bd8040eafc09b88bb0ec0fea59e8aacd1a801e688af087cead213b0d0/grpcio-tools-1.62.3.tar.gz", hash = "sha256:7c7136015c3d62c3eef493efabaf9e3380e3e66d24ee8e94c01cb71377f57833", size = 4538520 } 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, 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" }, + { 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 }, ] [[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, upload-time = "2025-04-24T03:35:25.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, ] [[package]] @@ -1377,34 +1389,34 @@ 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, upload-time = "2025-08-23T18:12:19.778Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779 }, ] [[package]] name = "hf-xet" version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, - { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, - { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, - { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814 }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444 }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986 }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865 }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835 }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414 }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238 }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916 }, ] [[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, upload-time = "2025-01-22T21:44:58.347Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 }, ] [[package]] @@ -1415,9 +1427,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, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] [[package]] @@ -1427,9 +1439,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, upload-time = "2026-01-23T11:04:44.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099 }, ] [[package]] @@ -1442,9 +1454,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, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [package.optional-dependencies] @@ -1467,36 +1479,36 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/b6/e22bd20a25299c34b8c5922c1545a6320825b13906eb0f7298edfd034a0b/huggingface_hub-1.15.0.tar.gz", hash = "sha256:28abfdddda3927fd4de6a63cf26ab012498a2c24dae52baf150c5c6edf98a1d5", size = 784100, upload-time = "2026-05-15T11:42:52.149Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/b6/e22bd20a25299c34b8c5922c1545a6320825b13906eb0f7298edfd034a0b/huggingface_hub-1.15.0.tar.gz", hash = "sha256:28abfdddda3927fd4de6a63cf26ab012498a2c24dae52baf150c5c6edf98a1d5", size = 784100 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/11/0b64cc9024329b76d7547c19a67604a61d21d3ba678a69d1b220c29d5112/huggingface_hub-1.15.0-py3-none-any.whl", hash = "sha256:a4a59af04cbc41a3fe3fec429b171ef994ef8c971eda10136746f408dd4e3744", size = 663602, upload-time = "2026-05-15T11:42:50.487Z" }, + { url = "https://files.pythonhosted.org/packages/6e/11/0b64cc9024329b76d7547c19a67604a61d21d3ba678a69d1b220c29d5112/huggingface_hub-1.15.0-py3-none-any.whl", hash = "sha256:a4a59af04cbc41a3fe3fec429b171ef994ef8c971eda10136746f408dd4e3744", size = 663602 }, ] [[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, upload-time = "2025-01-22T21:41:49.302Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 }, ] [[package]] name = "identify" version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567 } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397 }, ] [[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, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340 }, ] [[package]] @@ -1506,45 +1518,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, upload-time = "2024-09-11T14:56:08.937Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, ] [[package]] name = "inflection" version = "0.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091 } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454 }, ] [[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, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] [[package]] name = "invoke" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" }, + { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958 }, ] [[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, upload-time = "2024-10-08T23:04:11.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320 }, ] [[package]] @@ -1554,53 +1566,53 @@ 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, upload-time = "2025-03-05T20:05:02.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] [[package]] name = "jiter" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, - { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, - { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, - { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, - { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, - { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295 }, + { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898 }, + { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730 }, + { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102 }, + { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335 }, + { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536 }, + { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859 }, + { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626 }, + { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172 }, + { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300 }, + { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059 }, + { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030 }, + { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603 }, + { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525 }, + { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810 }, + { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443 }, + { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039 }, + { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613 }, ] [[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, upload-time = "2026-01-22T16:35:26.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 }, ] [[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, upload-time = "2025-12-15T08:41:46.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071 }, ] [[package]] @@ -1610,9 +1622,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881 } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464 }, ] [[package]] @@ -1625,9 +1637,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, upload-time = "2024-07-08T18:40:05.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, ] [[package]] @@ -1637,9 +1649,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, upload-time = "2025-09-08T01:34:59.186Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, ] [[package]] @@ -1652,9 +1664,9 @@ 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, upload-time = "2025-06-01T10:19:22.281Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034 }, ] [[package]] @@ -1664,16 +1676,16 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, - { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, - { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, - { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, - { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166 }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726 }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755 }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473 }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899 }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239 }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660 }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824 }, ] [[package]] @@ -1694,9 +1706,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/55/aebffceaa08688a989e9c68b3edc3a520a1f8338eb0346668774bd66ad88/litellm-1.85.1.tar.gz", hash = "sha256:3b8ef0c89ff2736cbd27109f17ff31f1bd0ab59dee9be8cadb28ec3cb167ce0d", size = 15346324, upload-time = "2026-05-21T02:30:38.185Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/55/aebffceaa08688a989e9c68b3edc3a520a1f8338eb0346668774bd66ad88/litellm-1.85.1.tar.gz", hash = "sha256:3b8ef0c89ff2736cbd27109f17ff31f1bd0ab59dee9be8cadb28ec3cb167ce0d", size = 15346324 } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/a0/263a13c2253201aa11563a69d9a87f3510030aa765a16f57fc40ceefcdf5/litellm-1.85.1-py3-none-any.whl", hash = "sha256:c89eb5dfd18cce3d40b59e79c74f7f645bc7814a417c6ab25e53c786f0a6ab7b", size = 16980080, upload-time = "2026-05-21T02:30:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/17/a0/263a13c2253201aa11563a69d9a87f3510030aa765a16f57fc40ceefcdf5/litellm-1.85.1-py3-none-any.whl", hash = "sha256:c89eb5dfd18cce3d40b59e79c74f7f645bc7814a417c6ab25e53c786f0a6ab7b", size = 16980080 }, ] [[package]] @@ -1708,9 +1720,9 @@ dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f3/f4d6520f8d546e6c5a02f6ebeed5c09774a074b8d2c24ad559ace97a56a6/llama_cloud-0.1.46.tar.gz", hash = "sha256:e86f8791c053590d70cc59e0fc13ce72f9b681a8e658bc61df86d0285288d8ee", size = 127752, upload-time = "2026-01-21T18:40:57.103Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f3/f4d6520f8d546e6c5a02f6ebeed5c09774a074b8d2c24ad559ace97a56a6/llama_cloud-0.1.46.tar.gz", hash = "sha256:e86f8791c053590d70cc59e0fc13ce72f9b681a8e658bc61df86d0285288d8ee", size = 127752 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/3a/6caaea28c8c804add33c91d356ed7d5a5412d6c9598e1450af95a15e0bcd/llama_cloud-0.1.46-py3-none-any.whl", hash = "sha256:6c6546c09c04a038c86d84d42f00eae8fd3bff49991ad3aab844bd866ecdf352", size = 361989, upload-time = "2026-01-21T18:40:54.863Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3a/6caaea28c8c804add33c91d356ed7d5a5412d6c9598e1450af95a15e0bcd/llama_cloud-0.1.46-py3-none-any.whl", hash = "sha256:6c6546c09c04a038c86d84d42f00eae8fd3bff49991ad3aab844bd866ecdf352", size = 361989 }, ] [[package]] @@ -1727,9 +1739,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/91/c3c94a58c44d0a12e0df2d5038b188fc283877f56cf2f6c41c60f43258e6/llama_cloud_services-0.6.94.tar.gz", hash = "sha256:127b8440d3d3a964d0c4b3f5fe7fcac3ead482f7645971cc8ae30768dcf63306", size = 64114, upload-time = "2026-02-13T23:29:40.454Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/91/c3c94a58c44d0a12e0df2d5038b188fc283877f56cf2f6c41c60f43258e6/llama_cloud_services-0.6.94.tar.gz", hash = "sha256:127b8440d3d3a964d0c4b3f5fe7fcac3ead482f7645971cc8ae30768dcf63306", size = 64114 } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/ab/876486e4f1c137cfeca8f876abd18eeec35a66a0fd8adb15afba7b28aa8c/llama_cloud_services-0.6.94-py3-none-any.whl", hash = "sha256:ac89785f3689d71298511f751bcf4ca16952a616bd75ff06e0ff164f04b0775b", size = 77098, upload-time = "2026-02-13T23:29:38.958Z" }, + { url = "https://files.pythonhosted.org/packages/14/ab/876486e4f1c137cfeca8f876abd18eeec35a66a0fd8adb15afba7b28aa8c/llama_cloud_services-0.6.94-py3-none-any.whl", hash = "sha256:ac89785f3689d71298511f751bcf4ca16952a616bd75ff06e0ff164f04b0775b", size = 77098 }, ] [[package]] @@ -1742,9 +1754,9 @@ dependencies = [ { name = "llama-index-llms-openai" }, { name = "nltk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/89/3b6f3318ea2249158daab3ff22777ef5ffa87a63c011659a6cfc55e54c35/llama_index-0.14.22.tar.gz", hash = "sha256:c2c9b31f50d2815abdc191085db4acaf96b7c01851ac66b2e4cc82be8cde589e", size = 8565, upload-time = "2026-05-14T20:22:21.006Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/89/3b6f3318ea2249158daab3ff22777ef5ffa87a63c011659a6cfc55e54c35/llama_index-0.14.22.tar.gz", hash = "sha256:c2c9b31f50d2815abdc191085db4acaf96b7c01851ac66b2e4cc82be8cde589e", size = 8565 } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/fd/f0837c4ce049d8ece7525bbf64564e93e3f16333856c2a0b47fecb58f317/llama_index-0.14.22-py3-none-any.whl", hash = "sha256:14b4bdd799112062e38288eab6aa16643f29d7532505ab174b0b6d5b0817fe94", size = 7115, upload-time = "2026-05-14T20:22:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/59/fd/f0837c4ce049d8ece7525bbf64564e93e3f16333856c2a0b47fecb58f317/llama_index-0.14.22-py3-none-any.whl", hash = "sha256:14b4bdd799112062e38288eab6aa16643f29d7532505ab174b0b6d5b0817fe94", size = 7115 }, ] [[package]] @@ -1781,9 +1793,9 @@ dependencies = [ { name = "typing-inspect" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/7f/94a4b940ef0d069840df0fd6d361a2aa832a2dd73b4cecdf86e8f8c353c8/llama_index_core-0.14.22.tar.gz", hash = "sha256:1384410f89bdbd32349aab444ef4f5c828c338787bc65bd1ffd8e86dfb44ac41", size = 11584786, upload-time = "2026-05-14T20:21:37.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/7f/94a4b940ef0d069840df0fd6d361a2aa832a2dd73b4cecdf86e8f8c353c8/llama_index_core-0.14.22.tar.gz", hash = "sha256:1384410f89bdbd32349aab444ef4f5c828c338787bc65bd1ffd8e86dfb44ac41", size = 11584786 } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/15/e1a26d8d56aa55fa07587a3e9c7e85294d2df5af6c2229193019bc549ef6/llama_index_core-0.14.22-py3-none-any.whl", hash = "sha256:9cfffde46fd5b7937101e1c0c9bb5c21bd7ff8c8a56937810b87ba3542f31225", size = 11920774, upload-time = "2026-05-14T20:21:40.409Z" }, + { url = "https://files.pythonhosted.org/packages/39/15/e1a26d8d56aa55fa07587a3e9c7e85294d2df5af6c2229193019bc549ef6/llama_index_core-0.14.22-py3-none-any.whl", hash = "sha256:9cfffde46fd5b7937101e1c0c9bb5c21bd7ff8c8a56937810b87ba3542f31225", size = 11920774 }, ] [[package]] @@ -1794,9 +1806,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "openai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/52/eb56a4887501651fb17400f7f571c1878109ff698efbe0bbac9165a5603d/llama_index_embeddings_openai-0.6.0.tar.gz", hash = "sha256:eb3e6606be81cb89125073e23c97c0a6119dabb4827adbd14697c2029ad73f29", size = 7629, upload-time = "2026-03-12T20:21:27.234Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/52/eb56a4887501651fb17400f7f571c1878109ff698efbe0bbac9165a5603d/llama_index_embeddings_openai-0.6.0.tar.gz", hash = "sha256:eb3e6606be81cb89125073e23c97c0a6119dabb4827adbd14697c2029ad73f29", size = 7629 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/d1/4bb0b80f4057903110060f617ef519197194b3ff5dd6153d850c8f5676fa/llama_index_embeddings_openai-0.6.0-py3-none-any.whl", hash = "sha256:039bb1007ad4267e25ddb89a206dfdab862bfb87d58da4271a3919e4f9df4d61", size = 7666, upload-time = "2026-03-12T20:21:28.079Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d1/4bb0b80f4057903110060f617ef519197194b3ff5dd6153d850c8f5676fa/llama_index_embeddings_openai-0.6.0-py3-none-any.whl", hash = "sha256:039bb1007ad4267e25ddb89a206dfdab862bfb87d58da4271a3919e4f9df4d61", size = 7666 }, ] [[package]] @@ -1807,9 +1819,9 @@ dependencies = [ { name = "deprecated" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/d0/671b23ccff255c9bce132a84ffd5a6f4541ceefdeab9c1786b08c9722f2e/llama_index_instrumentation-0.5.0.tar.gz", hash = "sha256:eeb724648b25d149de882a5ac9e21c5acb1ce780da214bda2b075341af29ad8e", size = 43831, upload-time = "2026-03-12T20:17:06.742Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/d0/671b23ccff255c9bce132a84ffd5a6f4541ceefdeab9c1786b08c9722f2e/llama_index_instrumentation-0.5.0.tar.gz", hash = "sha256:eeb724648b25d149de882a5ac9e21c5acb1ce780da214bda2b075341af29ad8e", size = 43831 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/45/6dcaccef44e541ffa138e4b45e33e0d40ab2a7d845338483954fcf77bc75/llama_index_instrumentation-0.5.0-py3-none-any.whl", hash = "sha256:aaab83cddd9dd434278891012d8995f47a3bc7ed1736a371db90965348c56a21", size = 16444, upload-time = "2026-03-12T20:17:05.957Z" }, + { url = "https://files.pythonhosted.org/packages/c3/45/6dcaccef44e541ffa138e4b45e33e0d40ab2a7d845338483954fcf77bc75/llama_index_instrumentation-0.5.0-py3-none-any.whl", hash = "sha256:aaab83cddd9dd434278891012d8995f47a3bc7ed1736a371db90965348c56a21", size = 16444 }, ] [[package]] @@ -1820,9 +1832,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "openai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/d5/2de9c05f1f1d21eb678a6044c59e943063e70099ac39b8b6f835e6e39511/llama_index_llms_openai-0.7.8.tar.gz", hash = "sha256:3352aed617ee5b7aefeb12719609ff84b4b590a1f49aa1e2e9c383d67ea88b0e", size = 27539, upload-time = "2026-05-08T20:02:09.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/d5/2de9c05f1f1d21eb678a6044c59e943063e70099ac39b8b6f835e6e39511/llama_index_llms_openai-0.7.8.tar.gz", hash = "sha256:3352aed617ee5b7aefeb12719609ff84b4b590a1f49aa1e2e9c383d67ea88b0e", size = 27539 } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/49/4250108a76f4f7622109ecb9c57861829f508aba0ffdc502b27134378505/llama_index_llms_openai-0.7.8-py3-none-any.whl", hash = "sha256:967aac1f4ceff99185b2cc425c2757d4fefaf3fac0a35ace247f87a212a29359", size = 28617, upload-time = "2026-05-08T20:02:10.583Z" }, + { url = "https://files.pythonhosted.org/packages/32/49/4250108a76f4f7622109ecb9c57861829f508aba0ffdc502b27134378505/llama_index_llms_openai-0.7.8-py3-none-any.whl", hash = "sha256:967aac1f4ceff99185b2cc425c2757d4fefaf3fac0a35ace247f87a212a29359", size = 28617 }, ] [[package]] @@ -1833,9 +1845,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, upload-time = "2026-01-13T11:46:41.394Z" } +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 } 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, upload-time = "2026-01-13T11:46:39.708Z" }, + { 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 }, ] [[package]] @@ -1846,9 +1858,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "pinecone" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/69/f51173b041d7273e0486634f14a6b437d147d40bbd1f767c4293c19f42c0/llama_index_vector_stores_pinecone-0.8.0.tar.gz", hash = "sha256:19e3ef74f638f4b693619d797d914fbcd55973ffa9f0ba28b3eb40ac3ff2da89", size = 7852, upload-time = "2026-03-12T20:47:52.405Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/69/f51173b041d7273e0486634f14a6b437d147d40bbd1f767c4293c19f42c0/llama_index_vector_stores_pinecone-0.8.0.tar.gz", hash = "sha256:19e3ef74f638f4b693619d797d914fbcd55973ffa9f0ba28b3eb40ac3ff2da89", size = 7852 } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/5c/4f3560524f97e89318350e78ba81af69fd435debf47d77740df4a3066444/llama_index_vector_stores_pinecone-0.8.0-py3-none-any.whl", hash = "sha256:49f324299ae06cae61231c1ed902d69a75c06083efb37087a484294c34344a3e", size = 8039, upload-time = "2026-03-12T20:47:51.419Z" }, + { url = "https://files.pythonhosted.org/packages/82/5c/4f3560524f97e89318350e78ba81af69fd435debf47d77740df4a3066444/llama_index_vector_stores_pinecone-0.8.0-py3-none-any.whl", hash = "sha256:49f324299ae06cae61231c1ed902d69a75c06083efb37087a484294c34344a3e", size = 8039 }, ] [[package]] @@ -1862,9 +1874,9 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "sqlalchemy", extra = ["asyncio"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/88/e89e75d7bd90b870e091157245c65ff89edddf5b6bedae83dbec4bdee28a/llama_index_vector_stores_postgres-0.8.1.tar.gz", hash = "sha256:e3f72f16f0a8776b610b44625b5fcab55a5977ce2fa5a7d3b162306a10d9b4e8", size = 14531, upload-time = "2026-03-13T15:21:52.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/88/e89e75d7bd90b870e091157245c65ff89edddf5b6bedae83dbec4bdee28a/llama_index_vector_stores_postgres-0.8.1.tar.gz", hash = "sha256:e3f72f16f0a8776b610b44625b5fcab55a5977ce2fa5a7d3b162306a10d9b4e8", size = 14531 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/60/e2b938d79cd7d0c4e58f33d1c62e5d1edc2b9a4ac8c4f7e613dd7c4e17a1/llama_index_vector_stores_postgres-0.8.1-py3-none-any.whl", hash = "sha256:832a79f2276b51cbb249e34cdb27911b2369c1b9a29e9d39359879bfe789b196", size = 14417, upload-time = "2026-03-13T15:21:51.064Z" }, + { url = "https://files.pythonhosted.org/packages/5d/60/e2b938d79cd7d0c4e58f33d1c62e5d1edc2b9a4ac8c4f7e613dd7c4e17a1/llama_index_vector_stores_postgres-0.8.1-py3-none-any.whl", hash = "sha256:832a79f2276b51cbb249e34cdb27911b2369c1b9a29e9d39359879bfe789b196", size = 14417 }, ] [[package]] @@ -1876,9 +1888,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "qdrant-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/9c/7df54b10e6f7d7b3fde3853d25f0c0ec6e701e9dea35359d316e5ea6d942/llama_index_vector_stores_qdrant-0.10.1.tar.gz", hash = "sha256:fef4ca8411c3e33636aabcf883941fde7a9d6deaa45254ee80627f2d0ffbf551", size = 14731, upload-time = "2026-05-04T15:17:50.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/9c/7df54b10e6f7d7b3fde3853d25f0c0ec6e701e9dea35359d316e5ea6d942/llama_index_vector_stores_qdrant-0.10.1.tar.gz", hash = "sha256:fef4ca8411c3e33636aabcf883941fde7a9d6deaa45254ee80627f2d0ffbf551", size = 14731 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/9d/32ac29c621db5e635cd248ef824cf49ea934c52085d19c409d6aea4d4167/llama_index_vector_stores_qdrant-0.10.1-py3-none-any.whl", hash = "sha256:90e8c0c0cd96309a9dd84a29cfe77aa6b3ef2f2596f0a0360eaaa8e3a004f1ff", size = 15000, upload-time = "2026-05-04T15:17:51.117Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9d/32ac29c621db5e635cd248ef824cf49ea934c52085d19c409d6aea4d4167/llama_index_vector_stores_qdrant-0.10.1-py3-none-any.whl", hash = "sha256:90e8c0c0cd96309a9dd84a29cfe77aa6b3ef2f2596f0a0360eaaa8e3a004f1ff", size = 15000 }, ] [[package]] @@ -1889,9 +1901,9 @@ dependencies = [ { name = "llama-index-core" }, { name = "weaviate-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/de/4c6e4aa714558fd61e76bbf2b3a46bf6e4b24553b7302fb07bc05c7e84f9/llama_index_vector_stores_weaviate-1.6.0.tar.gz", hash = "sha256:d88256f94f9e55d8e0a2f87e936f1481d016bdd3c871f142367b64ff179fcafa", size = 9678, upload-time = "2026-03-12T20:48:59.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/de/4c6e4aa714558fd61e76bbf2b3a46bf6e4b24553b7302fb07bc05c7e84f9/llama_index_vector_stores_weaviate-1.6.0.tar.gz", hash = "sha256:d88256f94f9e55d8e0a2f87e936f1481d016bdd3c871f142367b64ff179fcafa", size = 9678 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/01/069f641c7d8775392f8e2fc4f8a701b29d8818463207088a29f9ef1da04c/llama_index_vector_stores_weaviate-1.6.0-py3-none-any.whl", hash = "sha256:702bef9254ee68587084ba974c65c18cbd64223c24015499cf03c392aea6ba2b", size = 10438, upload-time = "2026-03-12T20:48:58.967Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/069f641c7d8775392f8e2fc4f8a701b29d8818463207088a29f9ef1da04c/llama_index_vector_stores_weaviate-1.6.0-py3-none-any.whl", hash = "sha256:702bef9254ee68587084ba974c65c18cbd64223c24015499cf03c392aea6ba2b", size = 10438 }, ] [[package]] @@ -1903,9 +1915,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/ec/05f3db99a2e6e252e3939e7751cad2fb1322dc6d32f4cf5c795cf7ddcad3/llama_index_workflows-2.20.0.tar.gz", hash = "sha256:df2760fea9e100c97a4e919d255461e344413acac4382d17d8217337806e4772", size = 97410, upload-time = "2026-04-24T14:54:41.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/ec/05f3db99a2e6e252e3939e7751cad2fb1322dc6d32f4cf5c795cf7ddcad3/llama_index_workflows-2.20.0.tar.gz", hash = "sha256:df2760fea9e100c97a4e919d255461e344413acac4382d17d8217337806e4772", size = 97410 } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/5f/385231406d777cb4b608fd8ebe3577dbd90962770717181e6b91b44fb1b8/llama_index_workflows-2.20.0-py3-none-any.whl", hash = "sha256:36f6b6ace77f837d9907078aea7e830251afe96a58daecff5ed090c88c55095d", size = 121238, upload-time = "2026-04-24T14:54:40.455Z" }, + { url = "https://files.pythonhosted.org/packages/71/5f/385231406d777cb4b608fd8ebe3577dbd90962770717181e6b91b44fb1b8/llama_index_workflows-2.20.0-py3-none-any.whl", hash = "sha256:36f6b6ace77f837d9907078aea7e830251afe96a58daecff5ed090c88c55095d", size = 121238 }, ] [[package]] @@ -1915,9 +1927,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llama-cloud-services" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/55/0d089a90fe98027853dd41dead1f094458df1a964e0d7c379b6a44208761/llama_parse-0.6.94.tar.gz", hash = "sha256:d9e4347ec6caa1e9d5266cc5d4d8b29a29aa0d0948d921a26c73d3e4aaf5ba72", size = 3996, upload-time = "2026-02-13T23:29:34.14Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/55/0d089a90fe98027853dd41dead1f094458df1a964e0d7c379b6a44208761/llama_parse-0.6.94.tar.gz", hash = "sha256:d9e4347ec6caa1e9d5266cc5d4d8b29a29aa0d0948d921a26c73d3e4aaf5ba72", size = 3996 } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/70/3d4cc14f99c80491401d4ab514f3ebe3113e38c8017cd384a73dc67b3ae4/llama_parse-0.6.94-py3-none-any.whl", hash = "sha256:48f21d909696597bf992acf5017685d88a6b25604bc1e98775021f39fe66649f", size = 5362, upload-time = "2026-02-13T23:29:35.432Z" }, + { url = "https://files.pythonhosted.org/packages/bd/70/3d4cc14f99c80491401d4ab514f3ebe3113e38c8017cd384a73dc67b3ae4/llama_parse-0.6.94-py3-none-any.whl", hash = "sha256:48f21d909696597bf992acf5017685d88a6b25604bc1e98775021f39fe66649f", size = 5362 }, ] [[package]] @@ -1928,9 +1940,9 @@ dependencies = [ { name = "requests" }, { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/f1/ac02558536cb97db15c2e6047f7943869f260299c700c3b5bce6821982e6/llmwhisperer_client-2.7.0.tar.gz", hash = "sha256:bebf3ca95b4a9a0802d290d88a834539e8054f68b1013298f1730dbcaafc69ea", size = 3268816, upload-time = "2026-03-16T10:44:42.234Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/f1/ac02558536cb97db15c2e6047f7943869f260299c700c3b5bce6821982e6/llmwhisperer_client-2.7.0.tar.gz", hash = "sha256:bebf3ca95b4a9a0802d290d88a834539e8054f68b1013298f1730dbcaafc69ea", size = 3268816 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/d9/b481433b644541a745c1d7de4fb2f53df088110ac34e4a1e89dc3ff9e28f/llmwhisperer_client-2.7.0-py3-none-any.whl", hash = "sha256:72903da9b7f07a2584792cb59094b703a31fc48ad33b6dee27a1ec14d84e0d18", size = 11093, upload-time = "2026-03-16T10:44:40.924Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d9/b481433b644541a745c1d7de4fb2f53df088110ac34e4a1e89dc3ff9e28f/llmwhisperer_client-2.7.0-py3-none-any.whl", hash = "sha256:72903da9b7f07a2584792cb59094b703a31fc48ad33b6dee27a1ec14d84e0d18", size = 11093 }, ] [[package]] @@ -1940,28 +1952,28 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, ] [[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, upload-time = "2025-09-27T18:37:40.426Z" } +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, 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/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 }, ] [[package]] @@ -1971,9 +1983,9 @@ 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, upload-time = "2025-12-22T06:53:53.309Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964 }, ] [[package]] @@ -1983,18 +1995,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/9c/dd6e38d747a62ead27f9abef32f4ca4311d4e40ac28e76bcc9ffb5dd0329/mbstrdecoder-1.1.5.tar.gz", hash = "sha256:8cbfba26938befd8a35e3cc06ca0632f61320b7b2be7df32550b895e1725b1ce", size = 14529, upload-time = "2026-05-05T04:17:58.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/9c/dd6e38d747a62ead27f9abef32f4ca4311d4e40ac28e76bcc9ffb5dd0329/mbstrdecoder-1.1.5.tar.gz", hash = "sha256:8cbfba26938befd8a35e3cc06ca0632f61320b7b2be7df32550b895e1725b1ce", size = 14529 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/eb/711270faab7b7df702339a2c68b31fd3ed4fffc68b0e99e5bdf49b1e87e4/mbstrdecoder-1.1.5-py3-none-any.whl", hash = "sha256:4a50fe113d4abecfd86e8f716b2e413cce03d63af83ec3c7cdbe81dec0e519ed", size = 7966, upload-time = "2026-05-05T04:17:56.78Z" }, + { url = "https://files.pythonhosted.org/packages/4c/eb/711270faab7b7df702339a2c68b31fd3ed4fffc68b0e99e5bdf49b1e87e4/mbstrdecoder-1.1.5-py3-none-any.whl", hash = "sha256:4a50fe113d4abecfd86e8f716b2e413cce03d63af83ec3c7cdbe81dec0e519ed", size = 7966 }, ] [[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, upload-time = "2022-08-14T12:40:10.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] [[package]] @@ -2007,9 +2019,9 @@ dependencies = [ { name = "numpy" }, { name = "pyarrow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/9a/d80d260e6fe1246818a8ef782c374ba9c6ca46ca3b987c14eabe914ef805/milvus_lite-3.0.tar.gz", hash = "sha256:2c35d0d046b1faae3402cde1fb73d65f51ee8c6aba65f54de1dda46f7bb18b9b", size = 589749, upload-time = "2026-05-13T07:14:05.827Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/9a/d80d260e6fe1246818a8ef782c374ba9c6ca46ca3b987c14eabe914ef805/milvus_lite-3.0.tar.gz", hash = "sha256:2c35d0d046b1faae3402cde1fb73d65f51ee8c6aba65f54de1dda46f7bb18b9b", size = 589749 } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/42/dee08ae3bfc1731572f193d00b248c9370b0b9dff12becb0ffd8b2ee8d56/milvus_lite-3.0-py3-none-any.whl", hash = "sha256:d9a094eab84bdaa4253da3721482282c939da1cce6f4e1759f947e8d3e53406e", size = 230490, upload-time = "2026-05-13T07:14:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/59/42/dee08ae3bfc1731572f193d00b248c9370b0b9dff12becb0ffd8b2ee8d56/milvus_lite-3.0-py3-none-any.whl", hash = "sha256:d9a094eab84bdaa4253da3721482282c939da1cce6f4e1759f947e8d3e53406e", size = 230490 }, ] [[package]] @@ -2023,9 +2035,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751 }, ] [[package]] @@ -2037,9 +2049,9 @@ dependencies = [ { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547 }, ] [[package]] @@ -2049,36 +2061,36 @@ 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, upload-time = "2025-03-14T23:51:03.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583 }, ] [[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, 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/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +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/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 }, ] [[package]] @@ -2089,41 +2101,41 @@ dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/1e/a587a862c766a755a58b62d8c00aed11b74a15dc415c1bf5da7b607b0efd/mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974", size = 2995901, upload-time = "2024-03-08T16:10:12.412Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/1e/a587a862c766a755a58b62d8c00aed11b74a15dc415c1bf5da7b607b0efd/mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974", size = 2995901 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/96/40f0f605b1d4e2ad1fb11d21988ce3a3e205886c0fcbd35c9789a214de9a/mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd", size = 10725390, upload-time = "2024-03-08T16:10:01.099Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d2/072e40384b53051106b4fcf03537fb88e2a6ad0757d2ab7f6c8c2f188a69/mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6", size = 9731292, upload-time = "2024-03-08T16:08:48.463Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/b7dc7eb69eda899fd07e71403b51b598a1f4df0f452d1da5844374082bcd/mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185", size = 12455450, upload-time = "2024-03-08T16:08:57.375Z" }, - { url = "https://files.pythonhosted.org/packages/1c/1b/3e962a201d2f0f57c9fa1990e0dd6076f4f2f94954ab56e4a701ec3cc070/mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913", size = 12530368, upload-time = "2024-03-08T16:09:17.061Z" }, - { url = "https://files.pythonhosted.org/packages/72/1f/8b214b69d08cc5e4bd8c3769ac55a43318f3529362ea55e5957774b69924/mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6", size = 9319112, upload-time = "2024-03-08T16:09:07.961Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/0ba2eaedca52bf5276275e8489951c26206030b3d31bf06f00875ae75d5d/mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e", size = 2555887, upload-time = "2024-03-08T16:09:48.584Z" }, + { url = "https://files.pythonhosted.org/packages/6e/96/40f0f605b1d4e2ad1fb11d21988ce3a3e205886c0fcbd35c9789a214de9a/mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd", size = 10725390 }, + { url = "https://files.pythonhosted.org/packages/d7/d2/072e40384b53051106b4fcf03537fb88e2a6ad0757d2ab7f6c8c2f188a69/mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6", size = 9731292 }, + { url = "https://files.pythonhosted.org/packages/85/a5/b7dc7eb69eda899fd07e71403b51b598a1f4df0f452d1da5844374082bcd/mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185", size = 12455450 }, + { url = "https://files.pythonhosted.org/packages/1c/1b/3e962a201d2f0f57c9fa1990e0dd6076f4f2f94954ab56e4a701ec3cc070/mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913", size = 12530368 }, + { url = "https://files.pythonhosted.org/packages/72/1f/8b214b69d08cc5e4bd8c3769ac55a43318f3529362ea55e5957774b69924/mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6", size = 9319112 }, + { url = "https://files.pythonhosted.org/packages/60/db/0ba2eaedca52bf5276275e8489951c26206030b3d31bf06f00875ae75d5d/mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e", size = 2555887 }, ] [[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, upload-time = "2025-04-22T14:54:24.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] [[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, upload-time = "2024-01-21T14:25:19.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, ] [[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, upload-time = "2025-12-08T17:02:39.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504 }, ] [[package]] @@ -2136,37 +2148,37 @@ 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, upload-time = "2026-03-24T06:13:40.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087 }, ] [[package]] name = "nodeenv" version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611 } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, ] [[package]] name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807 } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, - { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, - { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119 }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246 }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410 }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240 }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012 }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538 }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706 }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541 }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825 }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687 }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482 }, ] [[package]] @@ -2180,18 +2192,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, upload-time = "2018-09-07T21:38:18.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/7b/17244b1083e8e604bf154cf9b716aecd6388acd656dd01893d0d244c94d9/oauth2client-4.1.3.tar.gz", hash = "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6", size = 155910 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/95/a9/4f25a14d23f0786b64875b91784607c2277eff25d48f915e39ff0cff505a/oauth2client-4.1.3-py2.py3-none-any.whl", hash = "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac", size = 98206 }, ] [[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, upload-time = "2025-06-19T22:48:08.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, ] [[package]] @@ -2204,9 +2216,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, upload-time = "2025-05-11T10:24:21.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/04/6dce2d581c54a8e55a3b128cf79a93821a68a62bb9a956e65476c5bb247e/office365_rest_python_client-2.6.2.tar.gz", hash = "sha256:ce27f5a1c0cc3ff97041ccd9b386145692be4c64739f243f7d6ac3edbe0a3c46", size = 659460 } 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, upload-time = "2025-05-11T10:24:18.926Z" }, + { 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 }, ] [[package]] @@ -2223,9 +2235,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/50/5901f01ef14e6c27788beb91e54fef5d6204fb5fb9e97402fc8a14de2e32/openai-2.37.0.tar.gz", hash = "sha256:f4bc562cc5f3a43d40d678105572d9d44765f6e0f50c125f63055419b72f4bd9", size = 754706, upload-time = "2026-05-15T22:30:35.428Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/50/5901f01ef14e6c27788beb91e54fef5d6204fb5fb9e97402fc8a14de2e32/openai-2.37.0.tar.gz", hash = "sha256:f4bc562cc5f3a43d40d678105572d9d44765f6e0f50c125f63055419b72f4bd9", size = 754706 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/4c/bce61680d0699a78a405fd9a67989b175ba020590428831aab2ab1d2be7c/openai-2.37.0-py3-none-any.whl", hash = "sha256:814633888b8f3b1ffd6615697c6e4ef93632d08b7c2e28c8c5ef3556e5a10107", size = 1303238, upload-time = "2026-05-15T22:30:32.767Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4c/bce61680d0699a78a405fd9a67989b175ba020590428831aab2ab1d2be7c/openai-2.37.0-py3-none-any.whl", hash = "sha256:814633888b8f3b1ffd6615697c6e4ef93632d08b7c2e28c8c5ef3556e5a10107", size = 1303238 }, ] [[package]] @@ -2235,22 +2247,22 @@ 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, upload-time = "2024-08-20T21:02:35.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/c7/12632c03022aa5059ce9b6738397cda682dfda9d9afe7008b8a4f98c6ee5/oracledb-2.4.0.tar.gz", hash = "sha256:bdd61a9d5077448b5f1c58af6a14accc287bf8032846c351a3cdde5cf64fe95b", size = 614809 } 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, 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/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 }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, ] [[package]] @@ -2263,15 +2275,15 @@ 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, upload-time = "2025-09-29T23:34:51.853Z" } +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, 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/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 }, ] [[package]] @@ -2284,27 +2296,27 @@ 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, upload-time = "2026-05-09T18:28:52.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919 }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, ] [[package]] name = "pathvalidate" version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305 }, ] [[package]] @@ -2315,9 +2327,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, upload-time = "2025-12-30T15:49:13.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909 }, ] [[package]] @@ -2329,9 +2341,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, upload-time = "2026-01-05T08:10:29.072Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045 }, ] [[package]] @@ -2341,37 +2353,37 @@ 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, upload-time = "2025-12-05T01:07:17.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441 }, ] [[package]] name = "pika" version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/15/b6a706f1e886335aedc1e21d23913fe1a0fdaadd597d7721b26f11fe306a/pika-1.4.0.tar.gz", hash = "sha256:84aa6d0cf60bbdb79d5780544a4a4e1799392760127bf9de2a03d3c3b92f5f1a", size = 154264, upload-time = "2026-05-06T18:04:32.569Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/15/b6a706f1e886335aedc1e21d23913fe1a0fdaadd597d7721b26f11fe306a/pika-1.4.0.tar.gz", hash = "sha256:84aa6d0cf60bbdb79d5780544a4a4e1799392760127bf9de2a03d3c3b92f5f1a", size = 154264 } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/07/b7269c5367995648897a40ae37054780fc68b71afcc51c169a739c653d4b/pika-1.4.0-py3-none-any.whl", hash = "sha256:937d8576f92a1ce3673d442161fefef614ac557583e10b9d84c14a6e228ed6a7", size = 164964, upload-time = "2026-05-06T18:04:31.343Z" }, + { url = "https://files.pythonhosted.org/packages/dd/07/b7269c5367995648897a40ae37054780fc68b71afcc51c169a739c653d4b/pika-1.4.0-py3-none-any.whl", hash = "sha256:937d8576f92a1ce3673d442161fefef614ac557583e10b9d84c14a6e228ed6a7", size = 164964 }, ] [[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, upload-time = "2026-04-01T14:46:17.687Z" } +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, 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/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 }, ] [[package]] @@ -2385,45 +2397,45 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/9d/07a7f2136ce04cabd21d69c057dc2915867082b0047e6873e424388d4475/pinecone-7.0.1.tar.gz", hash = "sha256:49ff7b0f5be4a2ddec5aaa709758a9f2df56baa58ad46507d081409e246a81ec", size = 207930 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/81/88/896221e991077d353e61991b759f46d75f3b4298eb5a4aa6534c1371f4b0/pinecone-7.0.1-py3-none-any.whl", hash = "sha256:ce7b0dab3c9f7d81e75b24c13fcbca4a51371e08021faaecaf0cd9a45ca1be6c", size = 516590 }, ] [[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, upload-time = "2024-06-05T01:57:52.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/fb/e8a4063264953ead9e2b24d9b390152c60f042c951c47f4592e9996e57ff/pinecone_plugin_interface-0.0.7.tar.gz", hash = "sha256:b8e6675e41847333aa13923cc44daa3f85676d7157324682dc1640588a982846", size = 3370 } 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, upload-time = "2024-06-05T01:57:50.583Z" }, + { url = "https://files.pythonhosted.org/packages/3b/1d/a21fdfcd6d022cb64cef5c2a29ee6691c6c103c4566b41646b080b7536a5/pinecone_plugin_interface-0.0.7-py3-none-any.whl", hash = "sha256:875857ad9c9fc8bbc074dbe780d187a2afd21f5bfe0f3b08601924a61ef1bba8", size = 6249 }, ] [[package]] name = "platformdirs" version = "4.9.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400 } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348 }, ] [[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, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] [[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, upload-time = "2018-02-15T19:01:31.097Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567 }, ] [[package]] @@ -2433,9 +2445,9 @@ 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, upload-time = "2025-06-14T13:20:40.03Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 }, ] [[package]] @@ -2449,18 +2461,18 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/bd/8a672a86e68f542c3f2ae17a9a8fa63babf16d1107be2f5290e5aa4369ba/pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e", size = 177293, upload-time = "2024-02-18T18:19:41.431Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/bd/8a672a86e68f542c3f2ae17a9a8fa63babf16d1107be2f5290e5aa4369ba/pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e", size = 177293 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/7c/f7a50d07ae9fa86d2149d4acb2daf61e7c0257b56bc1a24a7fb09c1b70df/pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c", size = 204185, upload-time = "2024-02-18T18:19:38.953Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7c/f7a50d07ae9fa86d2149d4acb2daf61e7c0257b56bc1a24a7fb09c1b70df/pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c", size = 204185 }, ] [[package]] name = "prometheus-client" version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154 }, ] [[package]] @@ -2470,35 +2482,35 @@ 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, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, ] [[package]] name = "propcache" version = "0.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, - { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, - { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, - { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, - { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887 }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654 }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190 }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995 }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422 }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342 }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639 }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588 }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029 }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774 }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532 }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592 }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788 }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514 }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018 }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322 }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172 }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036 }, ] [[package]] @@ -2508,81 +2520,81 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410 }, ] [[package]] name = "protobuf" version = "4.25.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/d08c41a8c004e1d437ef467e7c4f9c3295cd784eba48ed5d1d01f94b1dad/protobuf-4.25.9.tar.gz", hash = "sha256:b0dc7e7c68de8b1ce831dacb12fb407e838edbb8b6cc0dc3a2a6b4cbf6de9cff", size = 381040, upload-time = "2026-03-25T23:09:36.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/d08c41a8c004e1d437ef467e7c4f9c3295cd784eba48ed5d1d01f94b1dad/protobuf-4.25.9.tar.gz", hash = "sha256:b0dc7e7c68de8b1ce831dacb12fb407e838edbb8b6cc0dc3a2a6b4cbf6de9cff", size = 381040 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/e9/59435bd04bdd46cb38c42a336b22f9843e8e586ff83c35a5423f8b14704e/protobuf-4.25.9-cp310-abi3-win32.whl", hash = "sha256:bde396f568b0b46fc8fbfe9f02facf25b6755b2578a3b8ac61e74b9d69499e03", size = 392879, upload-time = "2026-03-25T23:09:21.32Z" }, - { url = "https://files.pythonhosted.org/packages/f3/16/42a5c7f1001783d2b5bfcecde10127f09010f78982c86ae409122ce3ece6/protobuf-4.25.9-cp310-abi3-win_amd64.whl", hash = "sha256:3683c05154252206f7cb2d371626514b3708199d9bcf683b503dabf3a2e38e06", size = 413900, upload-time = "2026-03-25T23:09:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/56/5b/0074a0a9eb01f3d1c4648ca5e81b22090c811b210b61df9018ac6d6c5cda/protobuf-4.25.9-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:9560813560e6ee72c11ca8873878bdb7ee003c96a57ebb013245fe84e2540904", size = 394826, upload-time = "2026-03-25T23:09:25.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/b2dba856f64c36b2a06c67be1472de98cca07a2322d0f0cbf03279a40e5b/protobuf-4.25.9-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:999146ef02e7fa6a692477badd1528bcd7268df211852a3df2d834ba2b480791", size = 294191, upload-time = "2026-03-25T23:09:26.613Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5c/53f18822017b8bda6bd8bb4e02048e911fdc79a3dafdc83ab994fe922a84/protobuf-4.25.9-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:438c636de8fb706a0de94a12a268ef1ae8f5ba5ae655a7671fcda5968ba3c9be", size = 295178, upload-time = "2026-03-25T23:09:27.839Z" }, - { url = "https://files.pythonhosted.org/packages/16/28/d5065b212685875d3924bcdb3201cbf467cb4d58a18aa19a8dfd99ea80a9/protobuf-4.25.9-py3-none-any.whl", hash = "sha256:d49b615e7c935194ac161f0965699ac84df6112c378e05ec53da65d2e4cbb6d4", size = 156822, upload-time = "2026-03-25T23:09:34.957Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e9/59435bd04bdd46cb38c42a336b22f9843e8e586ff83c35a5423f8b14704e/protobuf-4.25.9-cp310-abi3-win32.whl", hash = "sha256:bde396f568b0b46fc8fbfe9f02facf25b6755b2578a3b8ac61e74b9d69499e03", size = 392879 }, + { url = "https://files.pythonhosted.org/packages/f3/16/42a5c7f1001783d2b5bfcecde10127f09010f78982c86ae409122ce3ece6/protobuf-4.25.9-cp310-abi3-win_amd64.whl", hash = "sha256:3683c05154252206f7cb2d371626514b3708199d9bcf683b503dabf3a2e38e06", size = 413900 }, + { url = "https://files.pythonhosted.org/packages/56/5b/0074a0a9eb01f3d1c4648ca5e81b22090c811b210b61df9018ac6d6c5cda/protobuf-4.25.9-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:9560813560e6ee72c11ca8873878bdb7ee003c96a57ebb013245fe84e2540904", size = 394826 }, + { url = "https://files.pythonhosted.org/packages/54/aa/b2dba856f64c36b2a06c67be1472de98cca07a2322d0f0cbf03279a40e5b/protobuf-4.25.9-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:999146ef02e7fa6a692477badd1528bcd7268df211852a3df2d834ba2b480791", size = 294191 }, + { url = "https://files.pythonhosted.org/packages/a8/5c/53f18822017b8bda6bd8bb4e02048e911fdc79a3dafdc83ab994fe922a84/protobuf-4.25.9-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:438c636de8fb706a0de94a12a268ef1ae8f5ba5ae655a7671fcda5968ba3c9be", size = 295178 }, + { url = "https://files.pythonhosted.org/packages/16/28/d5065b212685875d3924bcdb3201cbf467cb4d58a18aa19a8dfd99ea80a9/protobuf-4.25.9-py3-none-any.whl", hash = "sha256:d49b615e7c935194ac161f0965699ac84df6112c378e05ec53da65d2e4cbb6d4", size = 156822 }, ] [[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, upload-time = "2024-01-19T20:47:09.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/c7/6dc0a455d111f68ee43f27793971cf03fe29b6ef972042549db29eec39a2/psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c", size = 503247 } 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, 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" }, + { 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 }, ] [[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, upload-time = "2023-10-03T12:48:55.128Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/07/e720e53bfab016ebcc34241695ccc06a9e3d91ba19b40ca81317afbdc440/psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c", size = 384973 } 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, 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" }, + { 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 }, ] [[package]] name = "pyarrow" version = "18.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/7b/640785a9062bb00314caa8a387abce547d2a420cf09bd6c715fe659ccffb/pyarrow-18.1.0.tar.gz", hash = "sha256:9386d3ca9c145b5539a1cfc75df07757dff870168c959b473a0bccbc3abc8c73", size = 1118671, upload-time = "2024-11-26T02:01:48.62Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/7b/640785a9062bb00314caa8a387abce547d2a420cf09bd6c715fe659ccffb/pyarrow-18.1.0.tar.gz", hash = "sha256:9386d3ca9c145b5539a1cfc75df07757dff870168c959b473a0bccbc3abc8c73", size = 1118671 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/50/12829e7111b932581e51dda51d5cb39207a056c30fe31ef43f14c63c4d7e/pyarrow-18.1.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9f3a76670b263dc41d0ae877f09124ab96ce10e4e48f3e3e4257273cee61ad0d", size = 29514620, upload-time = "2024-11-26T01:59:39.797Z" }, - { url = "https://files.pythonhosted.org/packages/d1/41/468c944eab157702e96abab3d07b48b8424927d4933541ab43788bb6964d/pyarrow-18.1.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:da31fbca07c435be88a0c321402c4e31a2ba61593ec7473630769de8346b54ee", size = 30856494, upload-time = "2024-11-26T01:59:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/68/f9/29fb659b390312a7345aeb858a9d9c157552a8852522f2c8bad437c29c0a/pyarrow-18.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:543ad8459bc438efc46d29a759e1079436290bd583141384c6f7a1068ed6f992", size = 39203624, upload-time = "2024-11-26T01:59:49.189Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f6/19360dae44200e35753c5c2889dc478154cd78e61b1f738514c9f131734d/pyarrow-18.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0743e503c55be0fdb5c08e7d44853da27f19dc854531c0570f9f394ec9671d54", size = 40139341, upload-time = "2024-11-26T01:59:54.849Z" }, - { url = "https://files.pythonhosted.org/packages/bb/e6/9b3afbbcf10cc724312e824af94a2e993d8ace22994d823f5c35324cebf5/pyarrow-18.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d4b3d2a34780645bed6414e22dda55a92e0fcd1b8a637fba86800ad737057e33", size = 38618629, upload-time = "2024-11-26T01:59:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/3a/2e/3b99f8a3d9e0ccae0e961978a0d0089b25fb46ebbcfb5ebae3cca179a5b3/pyarrow-18.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c52f81aa6f6575058d8e2c782bf79d4f9fdc89887f16825ec3a66607a5dd8e30", size = 40078661, upload-time = "2024-11-26T02:00:04.55Z" }, - { url = "https://files.pythonhosted.org/packages/76/52/f8da04195000099d394012b8d42c503d7041b79f778d854f410e5f05049a/pyarrow-18.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ad4892617e1a6c7a551cfc827e072a633eaff758fa09f21c4ee548c30bcaf99", size = 25092330, upload-time = "2024-11-26T02:00:09.576Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/12829e7111b932581e51dda51d5cb39207a056c30fe31ef43f14c63c4d7e/pyarrow-18.1.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9f3a76670b263dc41d0ae877f09124ab96ce10e4e48f3e3e4257273cee61ad0d", size = 29514620 }, + { url = "https://files.pythonhosted.org/packages/d1/41/468c944eab157702e96abab3d07b48b8424927d4933541ab43788bb6964d/pyarrow-18.1.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:da31fbca07c435be88a0c321402c4e31a2ba61593ec7473630769de8346b54ee", size = 30856494 }, + { url = "https://files.pythonhosted.org/packages/68/f9/29fb659b390312a7345aeb858a9d9c157552a8852522f2c8bad437c29c0a/pyarrow-18.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:543ad8459bc438efc46d29a759e1079436290bd583141384c6f7a1068ed6f992", size = 39203624 }, + { url = "https://files.pythonhosted.org/packages/6e/f6/19360dae44200e35753c5c2889dc478154cd78e61b1f738514c9f131734d/pyarrow-18.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0743e503c55be0fdb5c08e7d44853da27f19dc854531c0570f9f394ec9671d54", size = 40139341 }, + { url = "https://files.pythonhosted.org/packages/bb/e6/9b3afbbcf10cc724312e824af94a2e993d8ace22994d823f5c35324cebf5/pyarrow-18.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d4b3d2a34780645bed6414e22dda55a92e0fcd1b8a637fba86800ad737057e33", size = 38618629 }, + { url = "https://files.pythonhosted.org/packages/3a/2e/3b99f8a3d9e0ccae0e961978a0d0089b25fb46ebbcfb5ebae3cca179a5b3/pyarrow-18.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c52f81aa6f6575058d8e2c782bf79d4f9fdc89887f16825ec3a66607a5dd8e30", size = 40078661 }, + { url = "https://files.pythonhosted.org/packages/76/52/f8da04195000099d394012b8d42c503d7041b79f778d854f410e5f05049a/pyarrow-18.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ad4892617e1a6c7a551cfc827e072a633eaff758fa09f21c4ee548c30bcaf99", size = 25092330 }, ] [[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, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997 }, ] [[package]] @@ -2592,9 +2604,9 @@ 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, upload-time = "2025-03-28T02:41:22.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, ] [[package]] @@ -2608,37 +2620,37 @@ dependencies = [ { name = "tomlkit" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/1b/be7329dcde1bf5126c7367ddc3b87945e3c9a208eb161b732bebad014eb9/pycln-2.6.0.tar.gz", hash = "sha256:758cfd07f0d9711f69989bd4cef7e451e3d7166073b0c0f77d4a712dbac8b3fe", size = 33427, upload-time = "2025-10-21T20:06:17.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/1b/be7329dcde1bf5126c7367ddc3b87945e3c9a208eb161b732bebad014eb9/pycln-2.6.0.tar.gz", hash = "sha256:758cfd07f0d9711f69989bd4cef7e451e3d7166073b0c0f77d4a712dbac8b3fe", size = 33427 } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/67/5aa8a63e1af76d29c67ff85d0c2f44dd294044fadacb19beb4bae7eafa01/pycln-2.6.0-py3-none-any.whl", hash = "sha256:32faabf77daf2a8995238f7522399d9e6f2dc40bcfe90e619299512cdb5bd8f2", size = 37745, upload-time = "2025-10-21T20:06:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/db/67/5aa8a63e1af76d29c67ff85d0c2f44dd294044fadacb19beb4bae7eafa01/pycln-2.6.0-py3-none-any.whl", hash = "sha256:32faabf77daf2a8995238f7522399d9e6f2dc40bcfe90e619299512cdb5bd8f2", size = 37745 }, ] [[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, upload-time = "2026-01-21T14:26:51.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, ] [[package]] name = "pycryptodome" version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276 } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, - { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, - { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, - { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, - { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, - { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, - { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, - { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627 }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362 }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625 }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954 }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534 }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853 }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465 }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414 }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484 }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636 }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 }, ] [[package]] @@ -2651,9 +2663,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, ] [[package]] @@ -2663,27 +2675,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527 }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024 }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696 }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 }, ] [[package]] @@ -2696,9 +2708,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, upload-time = "2023-05-21T02:25:57.217Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/74/b591079fa588351cec61861b85ba26f7deb96f3b445556c100e17db5572b/PyDrive2-1.15.4.tar.gz", hash = "sha256:0c011b74ebc24f3c6ca72820626b77f1dfe0ae88f5740c5a5cf96e83dd79ba99", size = 60514 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/18/f4/d0b40ee1c703304e8cc737e53516f834c0fbad4fe9b27aed7680d9fdf344/PyDrive2-1.15.4-py3-none-any.whl", hash = "sha256:91fe28e5f094a6dfff834495c4aee0041cbef979467ad27cd0d4b1f91afa8869", size = 45011 }, ] [package.optional-dependencies] @@ -2713,18 +2725,18 @@ fsspec = [ name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] [[package]] name = "pyjwt" version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726 }, ] [package.optional-dependencies] @@ -2745,37 +2757,37 @@ 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, upload-time = "2025-12-02T10:58:25.399Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/13/899185f025802ba80255faa8e45b3f3bf9cb7bab2d4235e12e3322c8e2a4/pymilvus-2.5.18.tar.gz", hash = "sha256:9e517076068e98dac51c018bc0dfe1f651d936154e2e2d9ad6c7b3dab1164e2d", size = 1285482 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c0/9c/a2b50b2b369814003460ca12a3c195fbf11b89bc1a861c2aa737c33ad7f9/pymilvus-2.5.18-py3-none-any.whl", hash = "sha256:1b78badcfa8d62db7d0b29193fc0422e4676873ff1c745a9d75c2c885d7a7e32", size = 244089 }, ] [[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, upload-time = "2025-04-02T02:08:43.503Z" } +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, 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/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 }, ] [[package]] name = "pymysql" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/9d/ee68dee1c8821c839bb31e6e5f40e61035a5278f7c1307dde758f0c90452/PyMySQL-1.1.0.tar.gz", hash = "sha256:4f13a7df8bf36a51e81dd9f3605fede45a4878fe02f9236349fd82a3f0612f96", size = 47240, upload-time = "2023-06-26T05:34:02.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/9d/ee68dee1c8821c839bb31e6e5f40e61035a5278f7c1307dde758f0c90452/PyMySQL-1.1.0.tar.gz", hash = "sha256:4f13a7df8bf36a51e81dd9f3605fede45a4878fe02f9236349fd82a3f0612f96", size = 47240 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/20467e39523d0cfc2b6227902d3687a16364307260c75e6a1cb4422b0c62/PyMySQL-1.1.0-py3-none-any.whl", hash = "sha256:8969ec6d763c856f7073c4c64662882675702efcb114b4bcbb955aea3a069fa7", size = 44768, upload-time = "2023-06-26T05:33:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/20467e39523d0cfc2b6227902d3687a16364307260c75e6a1cb4422b0c62/PyMySQL-1.1.0-py3-none-any.whl", hash = "sha256:8969ec6d763c856f7073c4c64662882675702efcb114b4bcbb955aea3a069fa7", size = 44768 }, ] [[package]] @@ -2785,22 +2797,22 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/c6/a3124dee667a423f2c637cfd262a54d67d8ccf3e160f3c50f622a85b7723/pynacl-1.6.0.tar.gz", hash = "sha256:cb36deafe6e2bce3b286e5d1f3e1c246e0ccdb8808ddb4550bb2792f2df298f2", size = 3505641, upload-time = "2025-09-10T23:39:22.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/c6/a3124dee667a423f2c637cfd262a54d67d8ccf3e160f3c50f622a85b7723/pynacl-1.6.0.tar.gz", hash = "sha256:cb36deafe6e2bce3b286e5d1f3e1c246e0ccdb8808ddb4550bb2792f2df298f2", size = 3505641 } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/37/87c72df19857c5b3b47ace6f211a26eb862ada495cc96daa372d96048fca/pynacl-1.6.0-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:f4b3824920e206b4f52abd7de621ea7a44fd3cb5c8daceb7c3612345dfc54f2e", size = 382610, upload-time = "2025-09-10T23:38:49.459Z" }, - { url = "https://files.pythonhosted.org/packages/0c/64/3ce958a5817fd3cc6df4ec14441c43fd9854405668d73babccf77f9597a3/pynacl-1.6.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:16dd347cdc8ae0b0f6187a2608c0af1c8b7ecbbe6b4a06bff8253c192f696990", size = 798744, upload-time = "2025-09-10T23:38:58.531Z" }, - { url = "https://files.pythonhosted.org/packages/e4/8a/3f0dd297a0a33fa3739c255feebd0206bb1df0b44c52fbe2caf8e8bc4425/pynacl-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16c60daceee88d04f8d41d0a4004a7ed8d9a5126b997efd2933e08e93a3bd850", size = 1397879, upload-time = "2025-09-10T23:39:00.44Z" }, - { url = "https://files.pythonhosted.org/packages/41/94/028ff0434a69448f61348d50d2c147dda51aabdd4fbc93ec61343332174d/pynacl-1.6.0-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25720bad35dfac34a2bcdd61d9e08d6bfc6041bebc7751d9c9f2446cf1e77d64", size = 833907, upload-time = "2025-09-10T23:38:50.936Z" }, - { url = "https://files.pythonhosted.org/packages/52/bc/a5cff7f8c30d5f4c26a07dfb0bcda1176ab8b2de86dda3106c00a02ad787/pynacl-1.6.0-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bfaa0a28a1ab718bad6239979a5a57a8d1506d0caf2fba17e524dbb409441cf", size = 1436649, upload-time = "2025-09-10T23:38:52.783Z" }, - { url = "https://files.pythonhosted.org/packages/7a/20/c397be374fd5d84295046e398de4ba5f0722dc14450f65db76a43c121471/pynacl-1.6.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ef214b90556bb46a485b7da8258e59204c244b1b5b576fb71848819b468c44a7", size = 817142, upload-time = "2025-09-10T23:38:54.4Z" }, - { url = "https://files.pythonhosted.org/packages/12/30/5efcef3406940cda75296c6d884090b8a9aad2dcc0c304daebb5ae99fb4a/pynacl-1.6.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:49c336dd80ea54780bcff6a03ee1a476be1612423010472e60af83452aa0f442", size = 1401794, upload-time = "2025-09-10T23:38:56.614Z" }, - { url = "https://files.pythonhosted.org/packages/be/e1/a8fe1248cc17ccb03b676d80fa90763760a6d1247da434844ea388d0816c/pynacl-1.6.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f3482abf0f9815e7246d461fab597aa179b7524628a4bc36f86a7dc418d2608d", size = 772161, upload-time = "2025-09-10T23:39:01.93Z" }, - { url = "https://files.pythonhosted.org/packages/a3/76/8a62702fb657d6d9104ce13449db221a345665d05e6a3fdefb5a7cafd2ad/pynacl-1.6.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:140373378e34a1f6977e573033d1dd1de88d2a5d90ec6958c9485b2fd9f3eb90", size = 1370720, upload-time = "2025-09-10T23:39:03.531Z" }, - { url = "https://files.pythonhosted.org/packages/6d/38/9e9e9b777a1c4c8204053733e1a0269672c0bd40852908c9ad6b6eaba82c/pynacl-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6b393bc5e5a0eb86bb85b533deb2d2c815666665f840a09e0aa3362bb6088736", size = 791252, upload-time = "2025-09-10T23:39:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/63/ef/d972ce3d92ae05c9091363cf185e8646933f91c376e97b8be79ea6e96c22/pynacl-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a25cfede801f01e54179b8ff9514bd7b5944da560b7040939732d1804d25419", size = 1362910, upload-time = "2025-09-10T23:39:06.924Z" }, - { url = "https://files.pythonhosted.org/packages/35/2c/ee0b373a1861f66a7ca8bdb999331525615061320dd628527a50ba8e8a60/pynacl-1.6.0-cp38-abi3-win32.whl", hash = "sha256:dcdeb41c22ff3c66eef5e63049abf7639e0db4edee57ba70531fc1b6b133185d", size = 226461, upload-time = "2025-09-10T23:39:11.894Z" }, - { url = "https://files.pythonhosted.org/packages/75/f7/41b6c0b9dd9970173b6acc026bab7b4c187e4e5beef2756d419ad65482da/pynacl-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:cf831615cc16ba324240de79d925eacae8265b7691412ac6b24221db157f6bd1", size = 238802, upload-time = "2025-09-10T23:39:08.966Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0f/462326910c6172fa2c6ed07922b22ffc8e77432b3affffd9e18f444dbfbb/pynacl-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:84709cea8f888e618c21ed9a0efdb1a59cc63141c403db8bf56c469b71ad56f2", size = 183846, upload-time = "2025-09-10T23:39:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/63/37/87c72df19857c5b3b47ace6f211a26eb862ada495cc96daa372d96048fca/pynacl-1.6.0-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:f4b3824920e206b4f52abd7de621ea7a44fd3cb5c8daceb7c3612345dfc54f2e", size = 382610 }, + { url = "https://files.pythonhosted.org/packages/0c/64/3ce958a5817fd3cc6df4ec14441c43fd9854405668d73babccf77f9597a3/pynacl-1.6.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:16dd347cdc8ae0b0f6187a2608c0af1c8b7ecbbe6b4a06bff8253c192f696990", size = 798744 }, + { url = "https://files.pythonhosted.org/packages/e4/8a/3f0dd297a0a33fa3739c255feebd0206bb1df0b44c52fbe2caf8e8bc4425/pynacl-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16c60daceee88d04f8d41d0a4004a7ed8d9a5126b997efd2933e08e93a3bd850", size = 1397879 }, + { url = "https://files.pythonhosted.org/packages/41/94/028ff0434a69448f61348d50d2c147dda51aabdd4fbc93ec61343332174d/pynacl-1.6.0-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25720bad35dfac34a2bcdd61d9e08d6bfc6041bebc7751d9c9f2446cf1e77d64", size = 833907 }, + { url = "https://files.pythonhosted.org/packages/52/bc/a5cff7f8c30d5f4c26a07dfb0bcda1176ab8b2de86dda3106c00a02ad787/pynacl-1.6.0-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bfaa0a28a1ab718bad6239979a5a57a8d1506d0caf2fba17e524dbb409441cf", size = 1436649 }, + { url = "https://files.pythonhosted.org/packages/7a/20/c397be374fd5d84295046e398de4ba5f0722dc14450f65db76a43c121471/pynacl-1.6.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ef214b90556bb46a485b7da8258e59204c244b1b5b576fb71848819b468c44a7", size = 817142 }, + { url = "https://files.pythonhosted.org/packages/12/30/5efcef3406940cda75296c6d884090b8a9aad2dcc0c304daebb5ae99fb4a/pynacl-1.6.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:49c336dd80ea54780bcff6a03ee1a476be1612423010472e60af83452aa0f442", size = 1401794 }, + { url = "https://files.pythonhosted.org/packages/be/e1/a8fe1248cc17ccb03b676d80fa90763760a6d1247da434844ea388d0816c/pynacl-1.6.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f3482abf0f9815e7246d461fab597aa179b7524628a4bc36f86a7dc418d2608d", size = 772161 }, + { url = "https://files.pythonhosted.org/packages/a3/76/8a62702fb657d6d9104ce13449db221a345665d05e6a3fdefb5a7cafd2ad/pynacl-1.6.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:140373378e34a1f6977e573033d1dd1de88d2a5d90ec6958c9485b2fd9f3eb90", size = 1370720 }, + { url = "https://files.pythonhosted.org/packages/6d/38/9e9e9b777a1c4c8204053733e1a0269672c0bd40852908c9ad6b6eaba82c/pynacl-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6b393bc5e5a0eb86bb85b533deb2d2c815666665f840a09e0aa3362bb6088736", size = 791252 }, + { url = "https://files.pythonhosted.org/packages/63/ef/d972ce3d92ae05c9091363cf185e8646933f91c376e97b8be79ea6e96c22/pynacl-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a25cfede801f01e54179b8ff9514bd7b5944da560b7040939732d1804d25419", size = 1362910 }, + { url = "https://files.pythonhosted.org/packages/35/2c/ee0b373a1861f66a7ca8bdb999331525615061320dd628527a50ba8e8a60/pynacl-1.6.0-cp38-abi3-win32.whl", hash = "sha256:dcdeb41c22ff3c66eef5e63049abf7639e0db4edee57ba70531fc1b6b133185d", size = 226461 }, + { url = "https://files.pythonhosted.org/packages/75/f7/41b6c0b9dd9970173b6acc026bab7b4c187e4e5beef2756d419ad65482da/pynacl-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:cf831615cc16ba324240de79d925eacae8265b7691412ac6b24221db157f6bd1", size = 238802 }, + { url = "https://files.pythonhosted.org/packages/8e/0f/462326910c6172fa2c6ed07922b22ffc8e77432b3affffd9e18f444dbfbb/pynacl-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:84709cea8f888e618c21ed9a0efdb1a59cc63141c403db8bf56c469b71ad56f2", size = 183846 }, ] [[package]] @@ -2811,47 +2823,47 @@ dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823 }, ] [[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, upload-time = "2026-01-21T03:57:59.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781 }, ] [[package]] name = "pypdfium2" version = "5.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/3d/dc934d3b606c51c3ecc95b6731d84b7dd7ab8e513a50b0e98a4da6c8a719/pypdfium2-5.8.0.tar.gz", hash = "sha256:049397c647e50f83115ee951c49394dab9e9ba52ebdd5a11ab1109390eb3d34e", size = 271934, upload-time = "2026-05-04T17:39:43.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/8c/6b75b923cb81368fa3ea7c48a0616b839620a3aeff899885bd930449b89e/pypdfium2-5.8.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:f67b6c74b716d9ac725ad1af49ae786ad813ac20823d45606d59f1fc06caa8af", size = 3374554, upload-time = "2026-05-04T17:39:05.552Z" }, - { url = "https://files.pythonhosted.org/packages/ef/61/a885c7f36efba89ec98e3d1fe95c83b48c2d6dea321e9194ac6460e7a834/pypdfium2-5.8.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:53e82bf3e6a2da170b1bda83f93b7eec57cb6efe3cacd05cba78823879a85203", size = 2831667, upload-time = "2026-05-04T17:39:08.028Z" }, - { url = "https://files.pythonhosted.org/packages/86/1f/04b5627f6dba312d3e707e5b019c9f24d8b03b5aa366866a9e02ec00f8d4/pypdfium2-5.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:085e633dcc89b65ff4035a4787e98ce7ae636836eb39c83dd0db26113d9774bc", size = 3450815, upload-time = "2026-05-04T17:39:09.551Z" }, - { url = "https://files.pythonhosted.org/packages/a9/77/8e3a2aba2bc4aef5abe1b1306d05b00588dc0bf7f5c850d1adf6164c786b/pypdfium2-5.8.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:bc84b7c6efede88fcfb9467f81daf416f26b973a54fc1cf4d3410d622fda6d7a", size = 3634395, upload-time = "2026-05-04T17:39:11.225Z" }, - { url = "https://files.pythonhosted.org/packages/93/11/6f2b1847d9fa457b3b7251afc2bba2706d104a0c6f01431dfae5d679a839/pypdfium2-5.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63bf09b2e13ba8545c930d243f0650c664a1b51314daa3b5f38df6d1a17b4bc", size = 3617413, upload-time = "2026-05-04T17:39:13.139Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fd/99ce639de5ca06d21743c740dd988cd209dda623bc763ae10b8a162022e1/pypdfium2-5.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:937881c1698456749ed203a58db1895baa5eb7178cdb837ef84867790638da28", size = 3347639, upload-time = "2026-05-04T17:39:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/fa/47/82864cc6e26dd8969d5594c168635acb16458d35cf5fed65d6b2e32abb42/pypdfium2-5.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be9dc2b84a8694ad7e626bab133244e8241014d5ed1930d865a9bdf90df1e24", size = 3746404, upload-time = "2026-05-04T17:39:17.094Z" }, - { url = "https://files.pythonhosted.org/packages/82/58/e41e49bba951f61921bac7289e67fe02af5ac57192d0bbfb5f459dc3691d/pypdfium2-5.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f27bd82891ae302dd02d736b14809661f6d1220ee1e96dbed9b23e2811922a3", size = 4177893, upload-time = "2026-05-04T17:39:18.729Z" }, - { url = "https://files.pythonhosted.org/packages/b4/15/fa7031010d5cf6853dadb4864680a0bfb7782c5bb6a1a401e0c25c4fca87/pypdfium2-5.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c1089cdbbdc7fe1248f6d17fe3f30214be4f287dd0196b31aaee18a1564240", size = 3665152, upload-time = "2026-05-04T17:39:20.207Z" }, - { url = "https://files.pythonhosted.org/packages/de/6a/5a3520a8b0cfa8d7fdc3f03a07ad9d6146c28ffd519330706f64fd8939a8/pypdfium2-5.8.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c038a9290864aaa4862dd32e591993d82551ca4d152b4e8ce6d43ba37dc04a8", size = 3095365, upload-time = "2026-05-04T17:39:22.054Z" }, - { url = "https://files.pythonhosted.org/packages/32/d3/845bae4de3cfa36865959046156edb5bf9baea400ccdecdd84fdd911b0f5/pypdfium2-5.8.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f104bc1a6d8bfc1ff088aa50db13b9729cfdb3722b44975c3c457e9a7b9c7318", size = 2961801, upload-time = "2026-05-04T17:39:23.817Z" }, - { url = "https://files.pythonhosted.org/packages/99/76/cf54eabee4a172241dfcfe63533bd1e11e2162114a983453a5a40bfec114/pypdfium2-5.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:04ca7c57a553facf8d46c6ea8ba6fa557e698670cfa4a58e0e01fdae2f6be87d", size = 4133067, upload-time = "2026-05-04T17:39:25.619Z" }, - { url = "https://files.pythonhosted.org/packages/77/66/dcf871d19187ca04ea184a99801a6e7e556d8347aa49540fee33cda6dfc5/pypdfium2-5.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ad42b9c22477b32dbedcbc8232833f385d92fd0cf92822547b02383cf9a476d7", size = 3749100, upload-time = "2026-05-04T17:39:27.203Z" }, - { url = "https://files.pythonhosted.org/packages/32/67/0d456c79660959ca45ad307b4d67161d29f9ed4083ee1e8fe8c6925b7c82/pypdfium2-5.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:388e3119cf5ca0979b7d5f6d40b7fcd5ab49e17ed4e6de6af89ba116061acfda", size = 4339212, upload-time = "2026-05-04T17:39:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/76/89/e5b0e0f7936be341c91c0f45cd70d693878894ed62aed93a6ee32e9c43c4/pypdfium2-5.8.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:aa05bbfa485ce7916217aa78d856c9f9cd86b08b20846c650392a67975ee72e9", size = 4383943, upload-time = "2026-05-04T17:39:31.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/21/4502ed255f082f579cd3537c2971cf1a57778d43703a08bcd1a92253189f/pypdfium2-5.8.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:f0813a16bb39d5ebd173ea5484430bb67a89b4b181db0a636c73b64ad063c3ea", size = 3925680, upload-time = "2026-05-04T17:39:33.241Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4f/2e59723e7a07779439bd885c1b4960079c9710603308888d29ac926ae69a/pypdfium2-5.8.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a3c78f7d20dd821bec6c072efdb21a1370b9efe10fdeeb68c969e67608e25385", size = 4269560, upload-time = "2026-05-04T17:39:34.926Z" }, - { url = "https://files.pythonhosted.org/packages/34/4e/7b6b1bde3788c8b880d4b8131d95d9d339cebafb3ad9102d82e234bb65be/pypdfium2-5.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:86d302e207c138c827b885a72784f7b306d840646ebeae07e8efdbc39321c629", size = 4182434, upload-time = "2026-05-04T17:39:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/11/7b/6ed4782e0d7a5278330598ce8c4b2df7255f4585a0b3d04520fa580d6507/pypdfium2-5.8.0-py3-none-win32.whl", hash = "sha256:3f25fd436920a907291462b41bdc0ab9f8235c3944b4c9c15398da595ffd1fed", size = 3636680, upload-time = "2026-05-04T17:39:38.49Z" }, - { url = "https://files.pythonhosted.org/packages/19/55/da7223d4202b2461f4f889b0baf10dddec3db7f88e6fd8c52db4a516eecd/pypdfium2-5.8.0-py3-none-win_amd64.whl", hash = "sha256:55592af0bddd2d62bed18e0053c546c9b72041430c5115e54870f7f6163125b0", size = 3754962, upload-time = "2026-05-04T17:39:40.13Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7a/f3dcefe6ee7389aad3ca1488c177e8fbf978206de21c7a99ccf487ea38ab/pypdfium2-5.8.0-py3-none-win_arm64.whl", hash = "sha256:3f17ed97ae8a5a1705301ca93af256a5b02f9009dee4e99c5e175831d46ebd7c", size = 3548362, upload-time = "2026-05-04T17:39:42.304Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6d/3d/dc934d3b606c51c3ecc95b6731d84b7dd7ab8e513a50b0e98a4da6c8a719/pypdfium2-5.8.0.tar.gz", hash = "sha256:049397c647e50f83115ee951c49394dab9e9ba52ebdd5a11ab1109390eb3d34e", size = 271934 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/8c/6b75b923cb81368fa3ea7c48a0616b839620a3aeff899885bd930449b89e/pypdfium2-5.8.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:f67b6c74b716d9ac725ad1af49ae786ad813ac20823d45606d59f1fc06caa8af", size = 3374554 }, + { url = "https://files.pythonhosted.org/packages/ef/61/a885c7f36efba89ec98e3d1fe95c83b48c2d6dea321e9194ac6460e7a834/pypdfium2-5.8.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:53e82bf3e6a2da170b1bda83f93b7eec57cb6efe3cacd05cba78823879a85203", size = 2831667 }, + { url = "https://files.pythonhosted.org/packages/86/1f/04b5627f6dba312d3e707e5b019c9f24d8b03b5aa366866a9e02ec00f8d4/pypdfium2-5.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:085e633dcc89b65ff4035a4787e98ce7ae636836eb39c83dd0db26113d9774bc", size = 3450815 }, + { url = "https://files.pythonhosted.org/packages/a9/77/8e3a2aba2bc4aef5abe1b1306d05b00588dc0bf7f5c850d1adf6164c786b/pypdfium2-5.8.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:bc84b7c6efede88fcfb9467f81daf416f26b973a54fc1cf4d3410d622fda6d7a", size = 3634395 }, + { url = "https://files.pythonhosted.org/packages/93/11/6f2b1847d9fa457b3b7251afc2bba2706d104a0c6f01431dfae5d679a839/pypdfium2-5.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63bf09b2e13ba8545c930d243f0650c664a1b51314daa3b5f38df6d1a17b4bc", size = 3617413 }, + { url = "https://files.pythonhosted.org/packages/ed/fd/99ce639de5ca06d21743c740dd988cd209dda623bc763ae10b8a162022e1/pypdfium2-5.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:937881c1698456749ed203a58db1895baa5eb7178cdb837ef84867790638da28", size = 3347639 }, + { url = "https://files.pythonhosted.org/packages/fa/47/82864cc6e26dd8969d5594c168635acb16458d35cf5fed65d6b2e32abb42/pypdfium2-5.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be9dc2b84a8694ad7e626bab133244e8241014d5ed1930d865a9bdf90df1e24", size = 3746404 }, + { url = "https://files.pythonhosted.org/packages/82/58/e41e49bba951f61921bac7289e67fe02af5ac57192d0bbfb5f459dc3691d/pypdfium2-5.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f27bd82891ae302dd02d736b14809661f6d1220ee1e96dbed9b23e2811922a3", size = 4177893 }, + { url = "https://files.pythonhosted.org/packages/b4/15/fa7031010d5cf6853dadb4864680a0bfb7782c5bb6a1a401e0c25c4fca87/pypdfium2-5.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c1089cdbbdc7fe1248f6d17fe3f30214be4f287dd0196b31aaee18a1564240", size = 3665152 }, + { url = "https://files.pythonhosted.org/packages/de/6a/5a3520a8b0cfa8d7fdc3f03a07ad9d6146c28ffd519330706f64fd8939a8/pypdfium2-5.8.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c038a9290864aaa4862dd32e591993d82551ca4d152b4e8ce6d43ba37dc04a8", size = 3095365 }, + { url = "https://files.pythonhosted.org/packages/32/d3/845bae4de3cfa36865959046156edb5bf9baea400ccdecdd84fdd911b0f5/pypdfium2-5.8.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f104bc1a6d8bfc1ff088aa50db13b9729cfdb3722b44975c3c457e9a7b9c7318", size = 2961801 }, + { url = "https://files.pythonhosted.org/packages/99/76/cf54eabee4a172241dfcfe63533bd1e11e2162114a983453a5a40bfec114/pypdfium2-5.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:04ca7c57a553facf8d46c6ea8ba6fa557e698670cfa4a58e0e01fdae2f6be87d", size = 4133067 }, + { url = "https://files.pythonhosted.org/packages/77/66/dcf871d19187ca04ea184a99801a6e7e556d8347aa49540fee33cda6dfc5/pypdfium2-5.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ad42b9c22477b32dbedcbc8232833f385d92fd0cf92822547b02383cf9a476d7", size = 3749100 }, + { url = "https://files.pythonhosted.org/packages/32/67/0d456c79660959ca45ad307b4d67161d29f9ed4083ee1e8fe8c6925b7c82/pypdfium2-5.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:388e3119cf5ca0979b7d5f6d40b7fcd5ab49e17ed4e6de6af89ba116061acfda", size = 4339212 }, + { url = "https://files.pythonhosted.org/packages/76/89/e5b0e0f7936be341c91c0f45cd70d693878894ed62aed93a6ee32e9c43c4/pypdfium2-5.8.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:aa05bbfa485ce7916217aa78d856c9f9cd86b08b20846c650392a67975ee72e9", size = 4383943 }, + { url = "https://files.pythonhosted.org/packages/82/21/4502ed255f082f579cd3537c2971cf1a57778d43703a08bcd1a92253189f/pypdfium2-5.8.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:f0813a16bb39d5ebd173ea5484430bb67a89b4b181db0a636c73b64ad063c3ea", size = 3925680 }, + { url = "https://files.pythonhosted.org/packages/7d/4f/2e59723e7a07779439bd885c1b4960079c9710603308888d29ac926ae69a/pypdfium2-5.8.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a3c78f7d20dd821bec6c072efdb21a1370b9efe10fdeeb68c969e67608e25385", size = 4269560 }, + { url = "https://files.pythonhosted.org/packages/34/4e/7b6b1bde3788c8b880d4b8131d95d9d339cebafb3ad9102d82e234bb65be/pypdfium2-5.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:86d302e207c138c827b885a72784f7b306d840646ebeae07e8efdbc39321c629", size = 4182434 }, + { url = "https://files.pythonhosted.org/packages/11/7b/6ed4782e0d7a5278330598ce8c4b2df7255f4585a0b3d04520fa580d6507/pypdfium2-5.8.0-py3-none-win32.whl", hash = "sha256:3f25fd436920a907291462b41bdc0ab9f8235c3944b4c9c15398da595ffd1fed", size = 3636680 }, + { url = "https://files.pythonhosted.org/packages/19/55/da7223d4202b2461f4f889b0baf10dddec3db7f88e6fd8c52db4a516eecd/pypdfium2-5.8.0-py3-none-win_amd64.whl", hash = "sha256:55592af0bddd2d62bed18e0053c546c9b72041430c5115e54870f7f6163125b0", size = 3754962 }, + { url = "https://files.pythonhosted.org/packages/fc/7a/f3dcefe6ee7389aad3ca1488c177e8fbf978206de21c7a99ccf487ea38ab/pypdfium2-5.8.0-py3-none-win_arm64.whl", hash = "sha256:3f17ed97ae8a5a1705301ca93af256a5b02f9009dee4e99c5e175831d46ebd7c", size = 3548362 }, ] [[package]] @@ -2867,9 +2879,9 @@ dependencies = [ { name = "tcolorpy" }, { name = "typepy", extra = ["datetime"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/a1/617730f290f04d347103ab40bf67d317df6691b14746f6e1ea039fb57062/pytablewriter-1.2.1.tar.gz", hash = "sha256:7bd0f4f397e070e3b8a34edcf1b9257ccbb18305493d8350a5dbc9957fced959", size = 619241, upload-time = "2025-01-01T15:37:00.04Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/a1/617730f290f04d347103ab40bf67d317df6691b14746f6e1ea039fb57062/pytablewriter-1.2.1.tar.gz", hash = "sha256:7bd0f4f397e070e3b8a34edcf1b9257ccbb18305493d8350a5dbc9957fced959", size = 619241 } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/4c/c199512f01c845dfe5a7840ab3aae6c60463b5dc2a775be72502dfd9170a/pytablewriter-1.2.1-py3-none-any.whl", hash = "sha256:e906ff7ff5151d70a5f66e0f7b75642a7f2dce8d893c265b79cc9cf6bc04ddb4", size = 91083, upload-time = "2025-01-01T15:36:55.63Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/c199512f01c845dfe5a7840ab3aae6c60463b5dc2a775be72502dfd9170a/pytablewriter-1.2.1-py3-none-any.whl", hash = "sha256:e906ff7ff5151d70a5f66e0f7b75642a7f2dce8d893c265b79cc9cf6bc04ddb4", size = 91083 }, ] [[package]] @@ -2883,9 +2895,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, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, ] [[package]] @@ -2897,9 +2909,9 @@ dependencies = [ { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, ] [[package]] @@ -2909,9 +2921,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156 } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, + { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123 }, ] [[package]] @@ -2924,9 +2936,9 @@ dependencies = [ { name = "tcolorpy" }, { name = "typepy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/2e/14711f6af84b1f637167b52aef179e307a580dfb54f7da8b0c06c3125453/pytest_md_report-0.8.0.tar.gz", hash = "sha256:c8e3b7f1f91a0e8e7d1b946e1b224f4f39187da0df2f812731361a436a17f472", size = 289649, upload-time = "2026-05-04T04:30:34.384Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/2e/14711f6af84b1f637167b52aef179e307a580dfb54f7da8b0c06c3125453/pytest_md_report-0.8.0.tar.gz", hash = "sha256:c8e3b7f1f91a0e8e7d1b946e1b224f4f39187da0df2f812731361a436a17f472", size = 289649 } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/47/c847f9095e466e5933570d3b603eee78389b3bfc534d017b43f6cc62fa1a/pytest_md_report-0.8.0-py3-none-any.whl", hash = "sha256:d2ba54b4be2071ea91bf3a17b215f70093fd5b8148356cb33f9a7a9ac53f177a", size = 17185, upload-time = "2026-05-04T04:30:32.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/47/c847f9095e466e5933570d3b603eee78389b3bfc534d017b43f6cc62fa1a/pytest_md_report-0.8.0-py3-none-any.whl", hash = "sha256:d2ba54b4be2071ea91bf3a17b215f70093fd5b8148356cb33f9a7a9ac53f177a", size = 17185 }, ] [[package]] @@ -2936,9 +2948,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, upload-time = "2025-09-16T16:37:27.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, ] [[package]] @@ -2948,9 +2960,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, upload-time = "2025-05-05T19:44:34.99Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 }, ] [[package]] @@ -2961,18 +2973,18 @@ dependencies = [ { name = "execnet" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396 }, ] [[package]] name = "python-crontab" version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626 } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533 }, ] [[package]] @@ -2982,9 +2994,9 @@ 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, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } 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, upload-time = "2024-03-01T18:36:18.57Z" }, + { 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 }, ] [[package]] @@ -2995,18 +3007,18 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185 }, ] [[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, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, ] [[package]] @@ -3016,18 +3028,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, upload-time = "2026-02-06T23:38:06.12Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847 }, ] [[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, upload-time = "2022-06-07T20:16:59.508Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677 } 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, upload-time = "2022-06-07T20:16:57.763Z" }, + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840 }, ] [[package]] @@ -3038,9 +3050,9 @@ 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, upload-time = "2026-02-06T23:42:07Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054 }, ] [[package]] @@ -3050,18 +3062,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "defusedxml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/4a/29feb8da6c44f77007dcd29518fea73a3d5653ee02a587ae1f17f1f5ddb5/python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", size = 305600, upload-time = "2020-06-29T12:15:49.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/4a/29feb8da6c44f77007dcd29518fea73a3d5653ee02a587ae1f17f1f5ddb5/python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", size = 305600 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/a5/c6ba13860bdf5525f1ab01e01cc667578d6f1efc8a1dba355700fb04c29b/python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b", size = 133681, upload-time = "2020-06-29T12:15:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a5/c6ba13860bdf5525f1ab01e01cc667578d6f1efc8a1dba355700fb04c29b/python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b", size = 133681 }, ] [[package]] name = "pytz" version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141 }, ] [[package]] @@ -3069,27 +3081,27 @@ 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, 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/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 }, ] [[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, upload-time = "2025-09-25T21:33:16.546Z" } +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, 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/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 }, ] [[package]] @@ -3105,18 +3117,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, upload-time = "2025-12-12T10:58:30.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/7d/3cd10e26ae97b35cf856ca1dc67576e42414ae39502c51165bb36bb1dff8/qdrant_client-1.16.2.tar.gz", hash = "sha256:ca4ef5f9be7b5eadeec89a085d96d5c723585a391eb8b2be8192919ab63185f0", size = 331112 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186 }, ] [[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, upload-time = "2024-12-06T09:50:41.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502 }, ] [[package]] @@ -3128,33 +3140,33 @@ dependencies = [ { name = "rpds-py" }, { name = "typing-extensions" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, ] [[package]] name = "regex" version = "2026.5.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074 } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, - { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, - { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, - { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, - { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, - { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, - { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, - { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, - { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, - { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, - { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451 }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112 }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599 }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732 }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440 }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329 }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239 }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054 }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098 }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095 }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762 }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100 }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479 }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699 }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783 }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513 }, ] [[package]] @@ -3167,9 +3179,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, upload-time = "2026-03-25T15:10:41.586Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 }, ] [[package]] @@ -3180,9 +3192,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, upload-time = "2024-03-22T20:32:29.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } 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, upload-time = "2024-03-22T20:32:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, ] [[package]] @@ -3192,9 +3204,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, upload-time = "2023-05-01T04:11:33.229Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } 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, upload-time = "2023-05-01T04:11:28.427Z" }, + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, ] [[package]] @@ -3205,32 +3217,32 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, ] [[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, upload-time = "2025-11-30T20:24:38.837Z" } +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, 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/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 }, ] [[package]] @@ -3240,34 +3252,34 @@ 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, upload-time = "2025-04-16T09:51:18.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, ] [[package]] name = "ruff" version = "0.15.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, - { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, - { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, - { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, - { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, - { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, - { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, - { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, - { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, - { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, - { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, - { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279 }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798 }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761 }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451 }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285 }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063 }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079 }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833 }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486 }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189 }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380 }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605 }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554 }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133 }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455 }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409 }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336 }, ] [[package]] @@ -3279,9 +3291,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, upload-time = "2024-10-21T01:45:49.967Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/65/4b4c868cff76c036d11dc75dd91e5696dbf16ce626514166f35d5f4a930f/s3fs-2024.10.0.tar.gz", hash = "sha256:58b8c3650f8b99dbedf361543da3533aac8707035a104db5d80b094617ad4a3f", size = 75916 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/99/44/bb9ff095ae7b1b6908480f683b6ca6b71c2105d343a5e5cb25334b01f5fa/s3fs-2024.10.0-py3-none-any.whl", hash = "sha256:7a2025d60d5b1a6025726b3a5e292a8e5aa713abc3b16fd1f81735181f7bb282", size = 29855 }, ] [package.optional-dependencies] @@ -3296,27 +3308,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, upload-time = "2024-11-20T21:06:05.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175 }, ] [[package]] name = "setuptools" version = "82.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223 }, ] [[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, upload-time = "2023-10-24T04:13:40.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] [[package]] @@ -3326,33 +3338,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, upload-time = "2024-10-10T22:39:31.412Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842 }, ] [[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, upload-time = "2017-08-10T19:52:45.903Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/98/a8b5c919bee1152a9a1afd82014431f8db5882699754de50d1b3aba4d136/singleton-decorator-1.0.0.tar.gz", hash = "sha256:1a90ad8a8a738be591c9c167fdd677c5d4a43d1bc6b1c128227be1c5e03bee07", size = 2791 } [[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, upload-time = "2024-12-04T17:35:28.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] [[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, upload-time = "2024-02-25T23:20:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] [[package]] @@ -3378,13 +3390,13 @@ 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, upload-time = "2026-05-28T13:01:48.582Z" } +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, 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/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 }, ] [package.optional-dependencies] @@ -3401,9 +3413,9 @@ dependencies = [ { name = "django" }, { name = "social-auth-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/f3/be7a7551463a6e7ddf9a4674662ae0fdea54aa4f4c82562d151cf1e41ced/social-auth-app-django-5.3.0.tar.gz", hash = "sha256:8719d57d01d80dcc9629a46e6806889aa9714fe4b658d2ebe3c120450591031d", size = 24519, upload-time = "2023-09-01T11:30:31.772Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/f3/be7a7551463a6e7ddf9a4674662ae0fdea54aa4f4c82562d151cf1e41ced/social-auth-app-django-5.3.0.tar.gz", hash = "sha256:8719d57d01d80dcc9629a46e6806889aa9714fe4b658d2ebe3c120450591031d", size = 24519 } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/65/747ad30653d67c9e65c3028b435a224f0fd9e81cf0bbeca2c889bbdf93ae/social_auth_app_django-5.3.0-py3-none-any.whl", hash = "sha256:2e71234656ddebe0c5b5ad450d42ee49f52a3f2d1708687fccf2a2c92d31a624", size = 26373, upload-time = "2023-09-01T11:30:30.18Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/747ad30653d67c9e65c3028b435a224f0fd9e81cf0bbeca2c889bbdf93ae/social_auth_app_django-5.3.0-py3-none-any.whl", hash = "sha256:2e71234656ddebe0c5b5ad450d42ee49f52a3f2d1708687fccf2a2c92d31a624", size = 26373 }, ] [[package]] @@ -3419,18 +3431,18 @@ dependencies = [ { name = "requests" }, { name = "requests-oauthlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/c0/466383c22767604c573f15aff3ea2c37aacf3c10281f31199c02ac0017ef/social_auth_core-4.7.0.tar.gz", hash = "sha256:2bba127c7b7166a81085ddb0c248d93751b3bc3cdab8569f62d9f70c6bc4ed40", size = 230894, upload-time = "2025-06-27T06:34:27.15Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/c0/466383c22767604c573f15aff3ea2c37aacf3c10281f31199c02ac0017ef/social_auth_core-4.7.0.tar.gz", hash = "sha256:2bba127c7b7166a81085ddb0c248d93751b3bc3cdab8569f62d9f70c6bc4ed40", size = 230894 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/3e/1b1ed868b840ecf5e7b02fc8ab20718ac24e184b90057815fee2ebbc107d/social_auth_core-4.7.0-py3-none-any.whl", hash = "sha256:9eef9b49c332d1a3265b37dcc698a7ace97c3fc59df2d874b51576d11d31f6a6", size = 427867, upload-time = "2025-06-27T06:34:25.715Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3e/1b1ed868b840ecf5e7b02fc8ab20718ac24e184b90057815fee2ebbc107d/social_auth_core-4.7.0-py3-none-any.whl", hash = "sha256:9eef9b49c332d1a3265b37dcc698a7ace97c3fc59df2d874b51576d11d31f6a6", size = 427867 }, ] [[package]] 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, upload-time = "2021-05-16T22:03:42.897Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, ] [[package]] @@ -3441,16 +3453,16 @@ 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/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221 } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, - { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681 }, + { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976 }, + { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937 }, + { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646 }, + { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695 }, + { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483 }, + { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494 }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158 }, ] [package.optional-dependencies] @@ -3462,9 +3474,9 @@ asyncio = [ name = "sqlparse" version = "0.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815 } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138 }, ] [[package]] @@ -3475,9 +3487,9 @@ 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, upload-time = "2022-01-25T21:32:16.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/6f/ef25bbc1aefeb9c905d527f1d3cd3f41f22f40566d33001b8bb14ae0cdaf/stone-3.3.1.tar.gz", hash = "sha256:4ef0397512f609757975f7ec09b35639d72ba7e3e17ce4ddf399578346b4cb50", size = 190888 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5c/92/d0c83f63d3518e5f0b8a311937c31347349ec9a47b209ddc17f7566f58fc/stone-3.3.1-py3-none-any.whl", hash = "sha256:e15866fad249c11a963cce3bdbed37758f2e88c8ff4898616bc0caeb1e216047", size = 162257 }, ] [[package]] @@ -3488,27 +3500,27 @@ dependencies = [ { name = "dataproperty" }, { name = "typepy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/65/2f54f0dedd775dde48e300023d20e13ad329a51e33dcadb6d47b4dc95768/tabledata-1.3.5.tar.gz", hash = "sha256:98c64d0ad6b520846b41000fb3f5b2f42fa7ca2675c2c669e5ccab6b93082a36", size = 25396, upload-time = "2026-05-11T12:03:26.367Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/65/2f54f0dedd775dde48e300023d20e13ad329a51e33dcadb6d47b4dc95768/tabledata-1.3.5.tar.gz", hash = "sha256:98c64d0ad6b520846b41000fb3f5b2f42fa7ca2675c2c669e5ccab6b93082a36", size = 25396 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/86/37fa0e1437089f08b8b1b8c8ad93f6b57e9427753f002914299323300a9e/tabledata-1.3.5-py3-none-any.whl", hash = "sha256:a1e57afc4767b51bef551114c0df31f205d712dbb75e3caf9be7834a79f23136", size = 11919, upload-time = "2026-05-11T12:03:24.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/86/37fa0e1437089f08b8b1b8c8ad93f6b57e9427753f002914299323300a9e/tabledata-1.3.5-py3-none-any.whl", hash = "sha256:a1e57afc4767b51bef551114c0df31f205d712dbb75e3caf9be7834a79f23136", size = 11919 }, ] [[package]] name = "tcolorpy" version = "0.1.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/cc/44f2d81d8f9093aad81c3467a5bf5718d2b5f786e887b6e4adcfc17ec6b9/tcolorpy-0.1.7.tar.gz", hash = "sha256:0fbf6bf238890bbc2e32662aa25736769a29bf6d880328f310c910a327632614", size = 299437, upload-time = "2024-12-29T15:24:23.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/cc/44f2d81d8f9093aad81c3467a5bf5718d2b5f786e887b6e4adcfc17ec6b9/tcolorpy-0.1.7.tar.gz", hash = "sha256:0fbf6bf238890bbc2e32662aa25736769a29bf6d880328f310c910a327632614", size = 299437 } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/a2/ed023f2edd1e011b4d99b6727bce8253842d66c3fbf9ed0a26fc09a92571/tcolorpy-0.1.7-py3-none-any.whl", hash = "sha256:26a59d52027e175a37e0aba72efc99dda43f074db71f55b316d3de37d3251378", size = 8096, upload-time = "2024-12-29T15:24:21.33Z" }, + { url = "https://files.pythonhosted.org/packages/05/a2/ed023f2edd1e011b4d99b6727bce8253842d66c3fbf9ed0a26fc09a92571/tcolorpy-0.1.7-py3-none-any.whl", hash = "sha256:26a59d52027e175a37e0aba72efc99dda43f074db71f55b316d3de37d3251378", size = 8096 }, ] [[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, upload-time = "2026-02-07T10:45:33.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 }, ] [[package]] @@ -3522,9 +3534,9 @@ dependencies = [ { name = "urllib3" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/b3/c272537f3ea2f312555efeb86398cc382cd07b740d5f3c730918c36e64e1/testcontainers-4.13.3.tar.gz", hash = "sha256:9d82a7052c9a53c58b69e1dc31da8e7a715e8b3ec1c4df5027561b47e2efe646", size = 79064, upload-time = "2025-11-14T05:08:47.584Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/b3/c272537f3ea2f312555efeb86398cc382cd07b740d5f3c730918c36e64e1/testcontainers-4.13.3.tar.gz", hash = "sha256:9d82a7052c9a53c58b69e1dc31da8e7a715e8b3ec1c4df5027561b47e2efe646", size = 79064 } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/27/c2f24b19dafa197c514abe70eda69bc031c5152c6b1f1e5b20099e2ceedd/testcontainers-4.13.3-py3-none-any.whl", hash = "sha256:063278c4805ffa6dd85e56648a9da3036939e6c0ac1001e851c9276b19b05970", size = 124784, upload-time = "2025-11-14T05:08:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/c2f24b19dafa197c514abe70eda69bc031c5152c6b1f1e5b20099e2ceedd/testcontainers-4.13.3-py3-none-any.whl", hash = "sha256:063278c4805ffa6dd85e56648a9da3036939e6c0ac1001e851c9276b19b05970", size = 124784 }, ] [package.optional-dependencies] @@ -3546,24 +3558,24 @@ 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, upload-time = "2025-10-06T20:22:45.419Z" } +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, 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/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 }, ] [[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, upload-time = "2026-03-15T18:48:01.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/59/8a8cb2331e2602b53e4dc06960f57d1387a2b18e7efd24e5f9cb60ea4925/tinytag-2.2.1.tar.gz", hash = "sha256:e6d06610ebe7cd66fd07be2d3b9495914ab32654a5e47657bb8cd44c2484523c", size = 38214 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ce/34/d50e338631baaf65ec5396e70085e5de0b52b24b28db1ffbc1c6e82190dc/tinytag-2.2.1-py3-none-any.whl", hash = "sha256:ed8b1e6d25367937e3321e054f4974f9abfde1a3e0a538824c87da377130c2b6", size = 32927 }, ] [[package]] @@ -3573,32 +3585,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, upload-time = "2026-01-05T10:45:15.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115 } 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, 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" }, + { 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 }, ] [[package]] name = "tomlkit" version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328 }, ] [[package]] @@ -3608,9 +3620,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, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374 }, ] [[package]] @@ -3620,9 +3632,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mbstrdecoder" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/9f/ae119b0e0fd0fe8dcb0e1eeebfeb62f37fdc0b467267cff15cdb746ba38b/typepy-1.3.5.tar.gz", hash = "sha256:a1c5f54c41860f89bab175f512b11e8c9a57cfe7b8b3d5ae5d52d828b756b6dd", size = 39883, upload-time = "2026-05-04T14:04:32.835Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/9f/ae119b0e0fd0fe8dcb0e1eeebfeb62f37fdc0b467267cff15cdb746ba38b/typepy-1.3.5.tar.gz", hash = "sha256:a1c5f54c41860f89bab175f512b11e8c9a57cfe7b8b3d5ae5d52d828b756b6dd", size = 39883 } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/71/75cf08c49b64a9419f1f2cef9be072ac34f6b784da2851489470b7c7ba15/typepy-1.3.5-py3-none-any.whl", hash = "sha256:de361b59609c7503efc2edbe9d7a4e053ae71307bf90ae1678ec4d6bcd807922", size = 31530, upload-time = "2026-05-04T14:04:31.46Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/75cf08c49b64a9419f1f2cef9be072ac34f6b784da2851489470b7c7ba15/typepy-1.3.5-py3-none-any.whl", hash = "sha256:de361b59609c7503efc2edbe9d7a4e053ae71307bf90ae1678ec4d6bcd807922", size = 31530 }, ] [package.optional-dependencies] @@ -3642,9 +3654,9 @@ 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, upload-time = "2026-02-13T10:04:30.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813 }, ] [[package]] @@ -3654,18 +3666,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/0b/b352742758a6054d1053783887bf8cfb739deda1102fda8722294bdc01f7/types_cffi-2.0.0.20260518.tar.gz", hash = "sha256:f9707e66c13454789a58f8843d1ded4a66f1e9c8b10bd24d5eb5e0f25c0c5472", size = 17790, upload-time = "2026-05-18T06:06:50.672Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/0b/b352742758a6054d1053783887bf8cfb739deda1102fda8722294bdc01f7/types_cffi-2.0.0.20260518.tar.gz", hash = "sha256:f9707e66c13454789a58f8843d1ded4a66f1e9c8b10bd24d5eb5e0f25c0c5472", size = 17790 } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/44/d3b4aafa20a3f76384ba19a513d39272add13746dcfe0409d8d4974fd464/types_cffi-2.0.0.20260518-py3-none-any.whl", hash = "sha256:5b68a215a95d0eac4203b58e766ff7fe40c2e091b1fa1a9e54111f04cc560084", size = 20198, upload-time = "2026-05-18T06:06:49.83Z" }, + { url = "https://files.pythonhosted.org/packages/68/44/d3b4aafa20a3f76384ba19a513d39272add13746dcfe0409d8d4974fd464/types_cffi-2.0.0.20260518-py3-none-any.whl", hash = "sha256:5b68a215a95d0eac4203b58e766ff7fe40c2e091b1fa1a9e54111f04cc560084", size = 20198 }, ] [[package]] name = "types-pymysql" version = "1.1.0.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/e0/43201060de33285af04263d9bd8e8c6b007bd8e0180bd46df8fe6576842e/types_pymysql-1.1.0.20260518.tar.gz", hash = "sha256:39a2448c4267dc4551e0824d2bfaecf7dfd171e89e6dbba90f4d4d45d55e4342", size = 22427, upload-time = "2026-05-18T06:02:31.239Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/e0/43201060de33285af04263d9bd8e8c6b007bd8e0180bd46df8fe6576842e/types_pymysql-1.1.0.20260518.tar.gz", hash = "sha256:39a2448c4267dc4551e0824d2bfaecf7dfd171e89e6dbba90f4d4d45d55e4342", size = 22427 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/5a/db02b5e6633fbe49eaf4e3194bc64ec031e6436a0cfcc610cbda4f1b6a24/types_pymysql-1.1.0.20260518-py3-none-any.whl", hash = "sha256:cf697ce4e44124fc859e8e8a7f047c1dc864745c3c628b85a51b3ee01502ef98", size = 23071, upload-time = "2026-05-18T06:02:30.36Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/db02b5e6633fbe49eaf4e3194bc64ec031e6436a0cfcc610cbda4f1b6a24/types_pymysql-1.1.0.20260518-py3-none-any.whl", hash = "sha256:cf697ce4e44124fc859e8e8a7f047c1dc864745c3c628b85a51b3ee01502ef98", size = 23071 }, ] [[package]] @@ -3676,27 +3688,27 @@ dependencies = [ { name = "cryptography" }, { name = "types-cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/38/011e2a9916e7afca2cc9c14fff1df42285d697ee0dd9903e4292cd1f5bf6/types-pyOpenSSL-24.0.0.20240417.tar.gz", hash = "sha256:38e75fb828d2717be173770bbae8c22811fdec68e2bc3f5833954113eb84237d", size = 8261, upload-time = "2024-04-17T02:17:34.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/38/011e2a9916e7afca2cc9c14fff1df42285d697ee0dd9903e4292cd1f5bf6/types-pyOpenSSL-24.0.0.20240417.tar.gz", hash = "sha256:38e75fb828d2717be173770bbae8c22811fdec68e2bc3f5833954113eb84237d", size = 8261 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/39/4e6dee712d1a93f2f2d39b0f7ebe0ba6168dfe2f6b50efe4b258790b5346/types_pyOpenSSL-24.0.0.20240417-py3-none-any.whl", hash = "sha256:4ce41ddaf383815168b6e21d542fd92135f10a5e82adb3e593a6b79638b0b511", size = 7420, upload-time = "2024-04-17T02:17:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/9d/39/4e6dee712d1a93f2f2d39b0f7ebe0ba6168dfe2f6b50efe4b258790b5346/types_pyOpenSSL-24.0.0.20240417-py3-none-any.whl", hash = "sha256:4ce41ddaf383815168b6e21d542fd92135f10a5e82adb3e593a6b79638b0b511", size = 7420 }, ] [[package]] name = "types-pytz" version = "2026.2.0.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/d9/9fa4019d2235bd374293e1fd4153879b28b6ae1d2bae98addd352c9713f2/types_pytz-2026.2.0.20260518.tar.gz", hash = "sha256:e5d254329e9c4e91f0781b22c43a4bb2d10bb044d97b24c4b05d45567b0eae16", size = 10871, upload-time = "2026-05-18T06:02:45.789Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/d9/9fa4019d2235bd374293e1fd4153879b28b6ae1d2bae98addd352c9713f2/types_pytz-2026.2.0.20260518.tar.gz", hash = "sha256:e5d254329e9c4e91f0781b22c43a4bb2d10bb044d97b24c4b05d45567b0eae16", size = 10871 } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/89/41e80670779a223d8bc8bc83019a619988cfa5c432cedac5cec23884fbc4/types_pytz-2026.2.0.20260518-py3-none-any.whl", hash = "sha256:3a12eaa38f476bd650902a9c9bb442f03f3c7dee2be5c5848bce61bd708d205a", size = 10125, upload-time = "2026-05-18T06:02:44.968Z" }, + { url = "https://files.pythonhosted.org/packages/62/89/41e80670779a223d8bc8bc83019a619988cfa5c432cedac5cec23884fbc4/types_pytz-2026.2.0.20260518-py3-none-any.whl", hash = "sha256:3a12eaa38f476bd650902a9c9bb442f03f3c7dee2be5c5848bce61bd708d205a", size = 10125 }, ] [[package]] name = "types-pyyaml" version = "6.0.12.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850 } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312 }, ] [[package]] @@ -3707,9 +3719,9 @@ dependencies = [ { name = "cryptography" }, { name = "types-pyopenssl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679, upload-time = "2024-10-04T02:43:59.224Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679 } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737, upload-time = "2024-10-04T02:43:57.968Z" }, + { url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737 }, ] [[package]] @@ -3719,18 +3731,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/b8/c1e8d39996b4929b918aba10dba5de07a8b3f4c8487bb61bb79882544e69/types-requests-2.31.0.6.tar.gz", hash = "sha256:cd74ce3b53c461f1228a9b783929ac73a666658f223e28ed29753771477b3bd0", size = 15535, upload-time = "2023-09-27T06:19:38.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/b8/c1e8d39996b4929b918aba10dba5de07a8b3f4c8487bb61bb79882544e69/types-requests-2.31.0.6.tar.gz", hash = "sha256:cd74ce3b53c461f1228a9b783929ac73a666658f223e28ed29753771477b3bd0", size = 15535 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/a1/6f8dc74d9069e790d604ddae70cb46dcbac668f1bb08136e7b0f2f5cd3bf/types_requests-2.31.0.6-py3-none-any.whl", hash = "sha256:a2db9cb228a81da8348b49ad6db3f5519452dd20a9c1e1a868c83c5fe88fd1a9", size = 14516, upload-time = "2023-09-27T06:19:36.373Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/6f8dc74d9069e790d604ddae70cb46dcbac668f1bb08136e7b0f2f5cd3bf/types_requests-2.31.0.6-py3-none-any.whl", hash = "sha256:a2db9cb228a81da8348b49ad6db3f5519452dd20a9c1e1a868c83c5fe88fd1a9", size = 14516 }, ] [[package]] name = "types-setuptools" version = "82.0.0.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/bc/73c2c27e047e42f114ac50fb3bdef986c56cbdb68096f8690eeafb839a93/types_setuptools-82.0.0.20260518.tar.gz", hash = "sha256:3b743cfe63d0981ea4c15b90710fc1ed41e3464a537d51e705be514e891c1d07", size = 44999, upload-time = "2026-05-18T06:02:55.642Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/bc/73c2c27e047e42f114ac50fb3bdef986c56cbdb68096f8690eeafb839a93/types_setuptools-82.0.0.20260518.tar.gz", hash = "sha256:3b743cfe63d0981ea4c15b90710fc1ed41e3464a537d51e705be514e891c1d07", size = 44999 } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/8f/d5e2d493f09a7a98c95619edda1cb37cee377626c0a869d53274c26f2858/types_setuptools-82.0.0.20260518-py3-none-any.whl", hash = "sha256:31c04a62b57a653a5021caf191be0f10f70df890f813b51f02bab3969d300f20", size = 68444, upload-time = "2026-05-18T06:02:54.582Z" }, + { url = "https://files.pythonhosted.org/packages/32/8f/d5e2d493f09a7a98c95619edda1cb37cee377626c0a869d53274c26f2858/types_setuptools-82.0.0.20260518-py3-none-any.whl", hash = "sha256:31c04a62b57a653a5021caf191be0f10f70df890f813b51f02bab3969d300f20", size = 68444 }, ] [[package]] @@ -3740,27 +3752,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-pytz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/cf/e4d446e57c0b14ed1da4de180d2a4cac773b667f183e83bdad76ea6e2238/types-tzlocal-5.1.0.1.tar.gz", hash = "sha256:b84a115c0c68f0d0fa9af1c57f0645eeef0e539147806faf1f95ac3ac01ce47b", size = 3549, upload-time = "2023-10-24T02:15:07.127Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/cf/e4d446e57c0b14ed1da4de180d2a4cac773b667f183e83bdad76ea6e2238/types-tzlocal-5.1.0.1.tar.gz", hash = "sha256:b84a115c0c68f0d0fa9af1c57f0645eeef0e539147806faf1f95ac3ac01ce47b", size = 3549 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/13/caeb438290df069ddda6f055d0eb14337ada293c7d43ab89419ba4b1a778/types_tzlocal-5.1.0.1-py3-none-any.whl", hash = "sha256:0302e8067c86936de8f7e0aaedc2cfbf240080802c603df0f80312fbd4efb926", size = 3005, upload-time = "2023-10-24T02:15:05.815Z" }, + { url = "https://files.pythonhosted.org/packages/f8/13/caeb438290df069ddda6f055d0eb14337ada293c7d43ab89419ba4b1a778/types_tzlocal-5.1.0.1-py3-none-any.whl", hash = "sha256:0302e8067c86936de8f7e0aaedc2cfbf240080802c603df0f80312fbd4efb926", size = 3005 }, ] [[package]] name = "types-urllib3" version = "1.26.25.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/de/b9d7a68ad39092368fb21dd6194b362b98a1daeea5dcfef5e1adb5031c7e/types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f", size = 11239, upload-time = "2023-07-20T15:19:31.307Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/de/b9d7a68ad39092368fb21dd6194b362b98a1daeea5dcfef5e1adb5031c7e/types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f", size = 11239 } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/7b/3fc711b2efea5e85a7a0bbfe269ea944aa767bbba5ec52f9ee45d362ccf3/types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e", size = 15377, upload-time = "2023-07-20T15:19:30.379Z" }, + { url = "https://files.pythonhosted.org/packages/11/7b/3fc711b2efea5e85a7a0bbfe269ea944aa767bbba5ec52f9ee45d362ccf3/types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e", size = 15377 }, ] [[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, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] [[package]] @@ -3771,9 +3783,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, upload-time = "2023-05-24T20:25:47.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, ] [[package]] @@ -3783,40 +3795,40 @@ 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, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, ] [[package]] name = "tzdata" version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 }, ] [[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, upload-time = "2026-05-05T22:05:01.354Z" } +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, 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/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" }, + { 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/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 }, ] [[package]] @@ -4171,9 +4183,11 @@ source = { editable = "workers" } dependencies = [ { name = "boto3" }, { name = "celery" }, + { name = "croniter" }, { name = "httpx" }, { name = "prometheus-client" }, { name = "psutil" }, + { name = "psycopg2-binary" }, { name = "python-dotenv" }, { name = "python-socketio" }, { name = "redis" }, @@ -4194,9 +4208,11 @@ dependencies = [ requires-dist = [ { name = "boto3", specifier = "~=1.34.0" }, { name = "celery", specifier = ">=5.5.3" }, + { name = "croniter", specifier = ">=3.0.3" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "prometheus-client", specifier = ">=0.17.0,<1.0.0" }, { name = "psutil", specifier = ">=5.9.0,<6.0.0" }, + { name = "psycopg2-binary", specifier = "==2.9.9" }, { name = "python-dotenv", specifier = ">=1.2.2,<2.0.0" }, { name = "python-socketio", specifier = ">=5.9.0" }, { name = "redis", specifier = ">=4.5.0,<6.0.0" }, @@ -4246,6 +4262,7 @@ dependencies = [ { name = "unstract-core" }, { name = "unstract-filesystem" }, { name = "unstract-flags" }, + { name = "unstract-sdk1" }, { name = "unstract-tool-registry" }, { name = "unstract-tool-sandbox" }, ] @@ -4255,6 +4272,7 @@ requires-dist = [ { name = "unstract-core", editable = "unstract/core" }, { name = "unstract-filesystem", editable = "unstract/filesystem" }, { name = "unstract-flags", editable = "unstract/flags" }, + { name = "unstract-sdk1", editable = "unstract/sdk1" }, { name = "unstract-tool-registry", editable = "unstract/tool-registry" }, { name = "unstract-tool-sandbox", editable = "unstract/tool-sandbox" }, ] @@ -4263,36 +4281,36 @@ 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, upload-time = "2025-06-02T15:12:06.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488 }, ] [[package]] name = "urllib3" version = "1.26.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380 } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225 }, ] [[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, upload-time = "2025-05-01T05:42:06.7Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/66/a435d9ae49850b2f071f7ebd8119dd4e84872b01630d6736761e6e7fd847/validators-0.35.0.tar.gz", hash = "sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a", size = 73399 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712 }, ] [[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, upload-time = "2023-11-05T08:46:53.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636 }, ] [[package]] @@ -4305,18 +4323,18 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554 }, ] [[package]] name = "wcwidth" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132 } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825 }, ] [[package]] @@ -4331,37 +4349,37 @@ dependencies = [ { name = "pydantic" }, { name = "validators" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/a2/6729149edc0bd5884bd0436186f7823f925ec489d47c327a3e408e514494/weaviate_client-4.21.0.tar.gz", hash = "sha256:050243b07f80349bbbaa3d426ace38466a972a073eff5cff62708ae5e9287dbe", size = 838731, upload-time = "2026-04-23T10:37:11.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/a2/6729149edc0bd5884bd0436186f7823f925ec489d47c327a3e408e514494/weaviate_client-4.21.0.tar.gz", hash = "sha256:050243b07f80349bbbaa3d426ace38466a972a073eff5cff62708ae5e9287dbe", size = 838731 } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/6b/5ae0aa935602f58fa2c2f5062c3faed8615a45df4b0406eb2d9deeeae73c/weaviate_client-4.21.0-py3-none-any.whl", hash = "sha256:82904bce3aae8f38a880e860195f4a17e6b55810708780f718132199030c8260", size = 639005, upload-time = "2026-04-23T10:37:09.668Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/5ae0aa935602f58fa2c2f5062c3faed8615a45df4b0406eb2d9deeeae73c/weaviate_client-4.21.0-py3-none-any.whl", hash = "sha256:82904bce3aae8f38a880e860195f4a17e6b55810708780f718132199030c8260", size = 639005 }, ] [[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, upload-time = "2025-10-07T21:16:36.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 }, ] [[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, upload-time = "2025-08-12T05:53:21.714Z" } +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, 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/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, + { 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/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 }, ] [[package]] @@ -4371,9 +4389,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, upload-time = "2025-11-20T18:18:01.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405 }, ] [[package]] @@ -4384,9 +4402,9 @@ dependencies = [ { name = "pathspec" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/a0/8fc2d68e132cf918f18273fdc8a1b8432b60d75ac12fdae4b0ef5c9d2e8d/yamllint-1.38.0.tar.gz", hash = "sha256:09e5f29531daab93366bb061e76019d5e91691ef0a40328f04c927387d1d364d", size = 142446, upload-time = "2026-01-13T07:47:53.276Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/a0/8fc2d68e132cf918f18273fdc8a1b8432b60d75ac12fdae4b0ef5c9d2e8d/yamllint-1.38.0.tar.gz", hash = "sha256:09e5f29531daab93366bb061e76019d5e91691ef0a40328f04c927387d1d364d", size = 142446 } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/92/aed08e68de6e6a3d7c2328ce7388072cd6affc26e2917197430b646aed02/yamllint-1.38.0-py3-none-any.whl", hash = "sha256:fc394a5b3be980a4062607b8fdddc0843f4fa394152b6da21722f5d59013c220", size = 68940, upload-time = "2026-01-13T07:47:51.343Z" }, + { url = "https://files.pythonhosted.org/packages/05/92/aed08e68de6e6a3d7c2328ce7388072cd6affc26e2917197430b646aed02/yamllint-1.38.0-py3-none-any.whl", hash = "sha256:fc394a5b3be980a4062607b8fdddc0843f4fa394152b6da21722f5d59013c220", size = 68940 }, ] [[package]] @@ -4398,43 +4416,43 @@ 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, 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/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +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/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288 }, ] [[package]] name = "zipp" version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238 }, ] [[package]] name = "zipstream-ng" version = "1.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/33/fce793430e56888cfe3d61199b0116fa42b95d54c2e0fe87b85829507d10/zipstream_ng-1.9.2.tar.gz", hash = "sha256:116b7304b00f3251328cb300fa90f0f09d523b7faf2a06b3eaf7277dcb82cc3e", size = 32446, upload-time = "2026-05-17T16:09:26.934Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/33/fce793430e56888cfe3d61199b0116fa42b95d54c2e0fe87b85829507d10/zipstream_ng-1.9.2.tar.gz", hash = "sha256:116b7304b00f3251328cb300fa90f0f09d523b7faf2a06b3eaf7277dcb82cc3e", size = 32446 } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/6e/103ba2f47ae052a9d91e4c3d4d7b1d45128045675325d99a476e9171fa2e/zipstream_ng-1.9.2-py3-none-any.whl", hash = "sha256:7292efc812a437ec688cef2c9523a4e710cd669e4b5abc7d5a15eb4d5e68e4ea", size = 23407, upload-time = "2026-05-17T16:09:25.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/103ba2f47ae052a9d91e4c3d4d7b1d45128045675325d99a476e9171fa2e/zipstream_ng-1.9.2-py3-none-any.whl", hash = "sha256:7292efc812a437ec688cef2c9523a4e710cd669e4b5abc7d5a15eb4d5e68e4ea", size = 23407 }, ] From d5b4a2faa3493c5024c47083d5d8fd9ceb091609 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:51:06 +0530 Subject: [PATCH 40/65] =?UTF-8?q?UN-3618=20[REFACTOR]=20PG=20Queue=20?= =?UTF-8?q?=E2=80=94=20gate=20solely=20on=20the=20Flipt=20flag=20(+=20UN-3?= =?UTF-8?q?619=20transient-connect=20retry)=20(#2109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3618 [REFACTOR] PG Queue — single-flag gating (drop PG_QUEUE_TRANSPORT_ENABLED) The pg_queue_enabled Flipt flag becomes the sole gate across all four resolvers (execution, scheduler, executor backend+workers). Removes the redundant env master-switch and the master_gate_enabled parameter from the shared resolve_pg_transport. Fully fail-closed (flag off / Flipt blind / Flipt error / no org -> Celery); verified by unit + real-stack dev-test. Co-Authored-By: Claude Opus 4.8 * UN-3619 [FEAT] PG Queue — bounded retry on transient Postgres connect create_pg_connection retries a transient psycopg2.OperationalError with bounded exponential backoff (WORKER_PG_QUEUE_CONNECT_RETRIES / _BACKOFF, defaults 3 / 0.5s). Connecting is side-effect-free so retry is safe; the enqueue INSERT is intentionally NOT retried (double-dispatch risk). Co-Authored-By: Claude Opus 4.8 * UN-3618 UN-3619 [REFACTOR] address PR #2109 review - Flipt observability (UN-3618): check_feature_flag_status / _variant now log a genuine evaluation failure (warning + exc_info) instead of silently returning the disabled default — the now-sole gate's outages are visible at the decision layer. Resolver defense-in-depth guards kept. - Update stale shared executor_rpc docstring (Flipt is the sole gate). - Remove now-dead module logger in workers executor_rpc. - Connect retry (UN-3619): clamp+warn out-of-range RETRIES (cap 10) / negative BACKOFF; drop the unreachable AssertionError sentinel via an inverted loop; document the backoff cap + that permanent misconfigs are retried-then-raised; document both knobs in sample.env. - Tests: add backoff-cap, unset-default, invalid/negative-backoff, attempts-clamp, geometric-growth, and params-forwarded/stable coverage (13 connection tests). Co-Authored-By: Claude Opus 4.8 * UN-3619 [FIX] pg_benchmark connection test — drop generator-throw lambda (SonarCloud) Replace the (_ for _ in ()).throw(...) generator trick with a plain local def connect that raises — matches the other tests' style and clears the SonarCloud comprehension finding. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/backend/settings/base.py | 8 - backend/pg_queue/executor_rpc.py | 16 +- backend/pg_queue/flags.py | 4 +- backend/pg_queue/tests/test_executor_rpc.py | 31 +-- backend/sample.env | 7 - backend/scheduler/ownership.py | 22 +- .../tests/test_pg_schedule_ownership.py | 35 +-- .../workflow_v2/tests/test_transport.py | 43 ++-- .../workflow_manager/workflow_v2/transport.py | 47 ++-- docker/docker-compose.yaml | 5 +- docker/sample.env | 6 +- .../flags/src/unstract/flags/feature_flag.py | 20 ++ .../workflow_execution/executor_rpc.py | 20 +- workers/queue_backend/pg_queue/connection.py | 135 +++++++++-- .../queue_backend/pg_queue/executor_rpc.py | 36 +-- workers/sample.env | 15 +- workers/tests/test_executor_rpc.py | 34 ++- workers/tests/test_pg_connection.py | 210 ++++++++++++++++++ 18 files changed, 451 insertions(+), 243 deletions(-) create mode 100644 workers/tests/test_pg_connection.py diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index 4b3fe0ce90..787a7603e6 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -151,14 +151,6 @@ def get_required_setting(setting_key: str, default: str | None = None) -> str | CELERY_BACKEND_DB_NAME = os.environ.get("CELERY_BACKEND_DB_NAME") or DB_NAME DEFAULT_ORGANIZATION = "default_org" FLIPT_BASE_URL = os.environ.get("FLIPT_BASE_URL", "http://localhost:9005") -# 9e PG-queue transport master-gate (kill-switch). When not "true", -# resolve_transport() never consults Flipt and every workflow execution rides -# Celery — the instant global rollback AND the deploy-ordering safety (stays off -# until PG consumers are running in the fleet). See -# workers/queue_backend/pg_queue/9e-design.md §2. -PG_QUEUE_TRANSPORT_ENABLED = CommonUtils.str_to_bool( - os.environ.get("PG_QUEUE_TRANSPORT_ENABLED", "False") -) PLATFORM_HOST = os.environ.get("PLATFORM_SERVICE_HOST", "http://localhost") PLATFORM_PORT = os.environ.get("PLATFORM_SERVICE_PORT", 3001) PROMPT_HOST = os.environ.get("PROMPT_HOST", "http://localhost") diff --git a/backend/pg_queue/executor_rpc.py b/backend/pg_queue/executor_rpc.py index c50850b2e9..cd850a2440 100644 --- a/backend/pg_queue/executor_rpc.py +++ b/backend/pg_queue/executor_rpc.py @@ -3,19 +3,18 @@ The gate + reply_key/timeout orchestration + routing live ONCE in ``unstract.workflow_execution.executor_rpc`` (shared with the workers). This module is the thin Django half: a :class:`DjangoQueueTransport` that enqueues via the ORM -(``enqueue_task``) and polls ``PgTaskResult``, plus the per-side gate (master switch = -``settings.PG_QUEUE_TRANSPORT_ENABLED``) and the :func:`get_executor_dispatcher` +(``enqueue_task``) and polls ``PgTaskResult``, plus the :func:`get_executor_dispatcher` factory that wires them together. -Zero-regression: gate off ⇒ the routing dispatcher delegates every mode to the -unchanged Celery ``ExecutionDispatcher`` and no ``pg_task_result`` row is created. +Zero-regression: with the ``pg_queue_enabled`` Flipt flag off the routing dispatcher +delegates every mode to the unchanged Celery ``ExecutionDispatcher`` and no +``pg_task_result`` row is created. """ from __future__ import annotations from typing import TYPE_CHECKING -from django.conf import settings from django.db import close_old_connections from pg_queue.models import PgTaskResult @@ -48,12 +47,9 @@ def resolve_executor_transport(context: ExecutionContext) -> bool: """True → route this executor dispatch over PG; False → Celery (default). - The backend gate: master switch ``settings.PG_QUEUE_TRANSPORT_ENABLED``, then the - shared Flipt eval (single ``pg_queue_enabled`` flag, fail-closed). + The single ``pg_queue_enabled`` Flipt flag (fail-closed). """ - return resolve_pg_transport( - context, master_gate_enabled=settings.PG_QUEUE_TRANSPORT_ENABLED - ) + return resolve_pg_transport(context) class DjangoQueueTransport(QueueTransport): diff --git a/backend/pg_queue/flags.py b/backend/pg_queue/flags.py index f2d56d8b73..cf190134c1 100644 --- a/backend/pg_queue/flags.py +++ b/backend/pg_queue/flags.py @@ -5,8 +5,8 @@ executor (``pg_queue/executor_rpc.py``) all read this one key. Kept in a neutral leaf module so the three resolvers import a single constant instead of duplicating the literal (a grep on ``PG_QUEUE_FLAG_KEY`` finds every use), making -"one flag" a structural guarantee. ``PG_QUEUE_TRANSPORT_ENABLED`` (env) remains -the master kill-switch consulted before this flag. +"one flag" a structural guarantee. This flag is the **sole** rollout control — +fail-closed to Celery on a blind/unreachable Flipt or any error. """ PG_QUEUE_FLAG_KEY = "pg_queue_enabled" diff --git a/backend/pg_queue/tests/test_executor_rpc.py b/backend/pg_queue/tests/test_executor_rpc.py index 1361b64f09..610c45ee0b 100644 --- a/backend/pg_queue/tests/test_executor_rpc.py +++ b/backend/pg_queue/tests/test_executor_rpc.py @@ -4,8 +4,8 @@ Flipt gate matrix live ONCE in the shared module and are covered in ``workers/tests/test_executor_rpc.py`` against a fake transport. This suite pins the **backend half**: the :class:`DjangoQueueTransport` (enqueue via the ORM, poll -``PgTaskResult`` → ``ExecResultRow``), the per-side gate (master switch = -``settings.PG_QUEUE_TRANSPORT_ENABLED``), and the factory wiring. +``PgTaskResult`` → ``ExecResultRow``), the resolver's delegation to the shared +single-Flipt-flag gate, and the factory wiring. """ from unittest.mock import MagicMock, patch @@ -95,31 +95,22 @@ def test_multi_iteration_poll_misses_then_hits(self): class TestResolveExecutorTransport: - @staticmethod - def _gate(on: bool): - s = MagicMock() - s.PG_QUEUE_TRANSPORT_ENABLED = on - return patch(f"{_MOD}.settings", s) - - def test_reads_settings_master_gate_on(self): - with self._gate(True), patch( - f"{_MOD}.resolve_pg_transport", return_value=True - ) as r: + def test_delegates_to_shared_flipt_resolver_true(self): + with patch(f"{_MOD}.resolve_pg_transport", return_value=True) as r: assert resolve_executor_transport(_ctx()) is True - assert r.call_args.kwargs["master_gate_enabled"] is True + r.assert_called_once() + # No master-gate is threaded any more — Flipt is the sole gate. + assert "master_gate_enabled" not in r.call_args.kwargs - def test_reads_settings_master_gate_off(self): - with self._gate(False), patch( - f"{_MOD}.resolve_pg_transport", return_value=False - ) as r: - resolve_executor_transport(_ctx()) - assert r.call_args.kwargs["master_gate_enabled"] is False + def test_delegates_to_shared_flipt_resolver_false(self): + with patch(f"{_MOD}.resolve_pg_transport", return_value=False): + assert resolve_executor_transport(_ctx()) is False class TestFactoryWiring: def test_factory_wires_routing_with_django_transport(self): d = get_executor_dispatcher(celery_app="app") assert isinstance(d, RoutingExecutionDispatcher) - # PG dispatcher uses the backend ORM transport; gate = the settings resolver. + # PG dispatcher uses the backend ORM transport; gate = the Flipt resolver. assert isinstance(d._pg._transport, DjangoQueueTransport) assert d._resolve is resolve_executor_transport diff --git a/backend/sample.env b/backend/sample.env index 30450faff3..377016fdec 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -184,13 +184,6 @@ TOOL_REGISTRY_CONFIG_PATH="/data/tool_registry_config" # Flipt Service FLIPT_SERVICE_AVAILABLE=False -# 9e PG-queue transport master-gate (kill-switch). Routing an execution onto the -# PG queue requires ALL THREE: this gate True, FLIPT_SERVICE_AVAILABLE=True (else -# the Flipt client returns False for every flag), and the Flipt flag -# `pg_queue_enabled` on. Keep False until PG queue consumers are running -# in the fleet; set False for instant rollback. -PG_QUEUE_TRANSPORT_ENABLED=False - # File System Configuration for Workflow and API Execution # Directory Prefixes for storing execution files diff --git a/backend/scheduler/ownership.py b/backend/scheduler/ownership.py index f5f3684419..039b583c84 100644 --- a/backend/scheduler/ownership.py +++ b/backend/scheduler/ownership.py @@ -12,9 +12,9 @@ ``PeriodicTask`` disabled, so the two can't both fire. Inert by default: ``resolve_schedule_owner`` fails closed to Beat -(``pg_owned=False``) until ops turns the master gate on AND ramps the single -``pg_queue_enabled`` Flipt flag — so reconciling on every schedule edit is a -no-op (everything stays Beat-owned) until the rollout starts. +(``pg_owned=False``) until ops ramps the single ``pg_queue_enabled`` Flipt flag — so +reconciling on every schedule edit is a no-op (everything stays Beat-owned) until the +rollout starts. """ from __future__ import annotations @@ -22,7 +22,6 @@ import logging import os -from django.conf import settings from django.db import transaction from django.utils import timezone from django_celery_beat.models import PeriodicTask @@ -41,19 +40,14 @@ def resolve_schedule_owner(pipeline_id: str, organization_id: str | None) -> bool: """True → the PG scheduler owns this schedule; False → Celery Beat does. - Mirrors ``resolve_transport``: master-gated by ``PG_QUEUE_TRANSPORT_ENABLED`` - (shared PG kill-switch), then the single ``pg_queue_enabled`` Flipt flag, keyed - on ``pipeline_id`` for a stable percentage bucket. **Fails closed to Beat** - on a closed gate, a blind Flipt, or any error — so a schedule never silently - loses its firer. + Mirrors ``resolve_transport``: gated by the single ``pg_queue_enabled`` Flipt + flag, keyed on ``pipeline_id`` for a stable percentage bucket. **Fails closed to + Beat** on a blind Flipt or any error — so a schedule never silently loses its + firer. """ - # Master gate off → never consult Flipt; every schedule stays on Beat. - if not settings.PG_QUEUE_TRANSPORT_ENABLED: - return False - if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": logger.warning( - "resolve_schedule_owner: gate ON but FLIPT_SERVICE_AVAILABLE != true " + "resolve_schedule_owner: FLIPT_SERVICE_AVAILABLE != true " "(Flipt blind) for pipeline %s; leaving on Beat", pipeline_id, ) diff --git a/backend/scheduler/tests/test_pg_schedule_ownership.py b/backend/scheduler/tests/test_pg_schedule_ownership.py index ed83030a81..61f16bc4ef 100644 --- a/backend/scheduler/tests/test_pg_schedule_ownership.py +++ b/backend/scheduler/tests/test_pg_schedule_ownership.py @@ -1,9 +1,9 @@ """Unit tests for the schedule-ownership ramp control (Phase 9, ②c). -DB-free: Flipt, settings, and the ORM (``PgPeriodicSchedule`` / ``PeriodicTask``) -are mocked. These pin the fail-closed rollout decision and — the load-bearing -property — that handing a schedule to PG disables its Beat ``PeriodicTask`` in -the same step (no double-fire), with pause state preserved. +DB-free: Flipt and the ORM (``PgPeriodicSchedule`` / ``PeriodicTask``) are mocked. +These pin the fail-closed rollout decision and — the load-bearing property — that +handing a schedule to PG disables its Beat ``PeriodicTask`` in the same step (no +double-fire), with pause state preserved. """ import contextlib @@ -17,32 +17,16 @@ _ORG = "org_abc" -def _gate(on: bool): - s = MagicMock() - s.PG_QUEUE_TRANSPORT_ENABLED = on - return patch("scheduler.ownership.settings", s) - - class TestResolveScheduleOwner: - def test_gate_off_is_beat(self, monkeypatch): - monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with _gate(False), patch( - "scheduler.ownership.check_feature_flag_status" - ) as flag: - assert ownership.resolve_schedule_owner(_PID, _ORG) is False - flag.assert_not_called() # gate off → Flipt never consulted - def test_flipt_unavailable_is_beat(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") - with _gate(True), patch( - "scheduler.ownership.check_feature_flag_status" - ) as flag: + with patch("scheduler.ownership.check_feature_flag_status") as flag: assert ownership.resolve_schedule_owner(_PID, _ORG) is False flag.assert_not_called() def test_flag_true_is_pg(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with _gate(True), patch( + with patch( "scheduler.ownership.check_feature_flag_status", return_value=True ) as flag: assert ownership.resolve_schedule_owner(_PID, _ORG) is True @@ -53,14 +37,14 @@ def test_flag_true_is_pg(self, monkeypatch): def test_flag_false_is_beat(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with _gate(True), patch( + with patch( "scheduler.ownership.check_feature_flag_status", return_value=False ): assert ownership.resolve_schedule_owner(_PID, _ORG) is False def test_flipt_error_fails_closed_to_beat(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - with _gate(True), patch( + with patch( "scheduler.ownership.check_feature_flag_status", side_effect=RuntimeError("flipt down"), ): @@ -159,7 +143,8 @@ class TestReconcileAtomicityRealDB: """The load-bearing invariant: the pg_owned write and the PeriodicTask write are ONE transaction — if the PeriodicTask update fails, pg_owned rolls back (so a schedule can't end up pg_owned with Beat still enabled). Needs a real - DB (the mocked atomic() can't prove rollback); skips if unreachable.""" + DB (the mocked atomic() can't prove rollback); skips if unreachable. + """ def test_periodictask_update_failure_rolls_back_pg_owned(self): import uuid diff --git a/backend/workflow_manager/workflow_v2/tests/test_transport.py b/backend/workflow_manager/workflow_v2/tests/test_transport.py index 2b0b100f63..a93c926f4a 100644 --- a/backend/workflow_manager/workflow_v2/tests/test_transport.py +++ b/backend/workflow_manager/workflow_v2/tests/test_transport.py @@ -1,15 +1,14 @@ """Tests for the 9e transport-resolution seam (PR 3 — Flipt canary wiring). ``resolve_transport`` is the single chokepoint that decides whether a new -execution rides the legacy Celery transport or the Postgres queue. It is gated -by an env master-switch and, when that is on, by a Flipt boolean flag; it fails -closed to Celery on any problem. These tests pin that contract. +execution rides the legacy Celery transport or the Postgres queue. It is gated by a +single ``pg_queue_enabled`` Flipt boolean flag; it fails closed to Celery on any +problem. These tests pin that contract. """ from unittest.mock import MagicMock, patch import pytest -from django.test import override_settings from unstract.core.data_models import ( DEFAULT_WORKFLOW_TRANSPORT, WorkflowTransport, @@ -37,58 +36,47 @@ def test_default_is_celery(self): class TestResolveTransport: @pytest.fixture(autouse=True) def _flipt_available(self, monkeypatch): - """The gate-ON path short-circuits to celery when Flipt is marked + """resolve_transport short-circuits to celery when Flipt is marked unavailable; default it available so the flag-evaluation tests exercise - the real path. Individual tests override as needed.""" + the real path. Individual tests override as needed. + """ monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=False) - def test_master_gate_off_never_consults_flipt(self): - """Master-gate off → Celery, and Flipt is not even called.""" - with patch(_FLIPT) as flipt: - result = resolve_transport(execution_id="e1", organization_id="org1") - assert result == WorkflowTransport.CELERY.value - flipt.assert_not_called() - - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) def test_missing_organization_forces_celery(self): """No org context → can't segment safely → fail closed; Flipt not called - (str(None)/"" must never reach the Flipt org segment).""" + (str(None)/"" must never reach the Flipt org segment). + """ with patch(_FLIPT) as flipt: result = resolve_transport(execution_id="e1", organization_id="") assert result == WorkflowTransport.CELERY.value flipt.assert_not_called() - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) - def test_gate_on_but_flipt_unavailable_forces_celery(self, monkeypatch): - """Gate ON + Flipt service unavailable → celery, loudly — not a silent - masquerade as a healthy 100%-celery canary.""" + def test_flipt_unavailable_forces_celery(self, monkeypatch): + """Flipt service unavailable → celery, loudly — not a silent masquerade as a + healthy 100%-celery rollout. + """ monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") with patch(_FLIPT) as flipt: result = resolve_transport(execution_id="e1", organization_id="org1") assert result == WorkflowTransport.CELERY.value flipt.assert_not_called() - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) - def test_gate_on_flipt_true_resolves_pg_queue(self): + def test_flipt_true_resolves_pg_queue(self): with patch(_FLIPT, return_value=True): result = resolve_transport(execution_id="e1", organization_id="org1") assert result == WorkflowTransport.PG_QUEUE.value - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) - def test_gate_on_flipt_false_resolves_celery(self): + def test_flipt_false_resolves_celery(self): with patch(_FLIPT, return_value=False): result = resolve_transport(execution_id="e1", organization_id="org1") assert result == WorkflowTransport.CELERY.value - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) def test_flipt_exception_fails_closed_to_celery(self): """A Flipt outage must never break execution creation.""" with patch(_FLIPT, side_effect=RuntimeError("flipt down")): result = resolve_transport(execution_id="e1", organization_id="org1") assert result == WorkflowTransport.CELERY.value - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) def test_passes_execution_id_as_entity_and_builds_context(self): """entity_id = execution_id (sticky bucketing); context carries the org/workflow/pipeline for segment rules. @@ -110,7 +98,6 @@ def test_passes_execution_id_as_entity_and_builds_context(self): }, ) - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) def test_non_string_ids_are_coerced_for_flipt(self): """Callers pass UUID objects for the ids. Flipt's context is a gRPC map and entity_id must hash stably, so every value must @@ -134,14 +121,12 @@ def test_non_string_ids_are_coerced_for_flipt(self): assert kwargs["context"]["workflow_id"] == str(wf) assert kwargs["context"]["pipeline_id"] == str(pl) - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) def test_context_omits_unset_optional_ids(self): with patch(_FLIPT, return_value=True) as flipt: resolve_transport(execution_id="exec-42", organization_id="org1") _, kwargs = flipt.call_args assert kwargs["context"] == {"organization_id": "org1"} - @override_settings(PG_QUEUE_TRANSPORT_ENABLED=True) def test_result_is_a_valid_transport_value(self): valid = {t.value for t in WorkflowTransport} with patch(_FLIPT, return_value=True): diff --git a/backend/workflow_manager/workflow_v2/transport.py b/backend/workflow_manager/workflow_v2/transport.py index 337e3dfe1d..08b2f31c24 100644 --- a/backend/workflow_manager/workflow_v2/transport.py +++ b/backend/workflow_manager/workflow_v2/transport.py @@ -8,25 +8,23 @@ row: the payload is the single carrier, durable for PG via the queue row's JSONB, and the giant shared table is never migrated for this work. -PR 3 (this change) replaces PR 1's hardwired Celery with a Flipt evaluation: +The transport is resolved from a single Flipt evaluation: - master-gate (env) → Flipt boolean (``pg_queue_enabled``) → transport + Flipt boolean (``pg_queue_enabled``) → transport -Routing onto PG needs **all three** of: the env master-gate on, Flipt reachable -(``FLIPT_SERVICE_AVAILABLE=true``), and the flag enabled for this execution. +Routing onto PG needs **both**: Flipt reachable (``FLIPT_SERVICE_AVAILABLE=true``) +and the single ``pg_queue_enabled`` flag enabled for this execution. The flag is the +sole rollout control — flip it to ramp PG, flip it off (or a Flipt outage) to fall +back to Celery. -- **Master-gate** (``settings.PG_QUEUE_TRANSPORT_ENABLED``, default off): until - ops flips it on, Flipt is never consulted and every execution rides Celery. - This is both the instant global kill-switch *and* the deploy-ordering safety — - the flag stays inert until PG consumers are actually running in the fleet. - **Flipt** decides per-execution: ``entity_id = execution_id`` drives the percentage-rollout hashing (an execution resolves exactly once, so it can never re-bucket mid-flight); ``context`` carries org/workflow/pipeline for segment rules. The flag contract is fixed in 9e-design §2. -- **Fail-closed to Celery**: a Flipt outage must never break execution creation, - mirroring ``normalize_transport`` on the read side. The gate-ON path logs its - decision so a "gate on but still all Celery" situation (e.g. a blind Flipt) - is visible rather than silent. +- **Fail-closed to Celery**: a blind/unreachable Flipt, a missing org, or any error + must never break execution creation — it resolves to Celery, mirroring + ``normalize_transport`` on the read side. The decision is logged so a "flag on but + still all Celery" situation (e.g. a blind Flipt) is visible rather than silent. """ from __future__ import annotations @@ -35,7 +33,6 @@ import os from typing import TYPE_CHECKING -from django.conf import settings from pg_queue.flags import PG_QUEUE_FLAG_KEY from unstract.core.data_models import WorkflowTransport @@ -78,22 +75,12 @@ def resolve_transport( pipeline_id: Optional, carried in ``context`` for future segment rules. Returns: - A :class:`WorkflowTransport` value string — ``"pg_queue"`` only when the - master-gate is on, Flipt is reachable, and Flipt says yes for this - execution; ``"celery"`` otherwise (including any error — fail-closed). + A :class:`WorkflowTransport` value string — ``"pg_queue"`` only when Flipt + is reachable and says yes for this execution; ``"celery"`` otherwise + (including any error — fail-closed). """ celery = WorkflowTransport.CELERY.value - # Master-gate: until ops sets PG_QUEUE_TRANSPORT_ENABLED=true, never consult - # Flipt — every execution rides Celery (kill-switch + deploy-ordering safety). - # Intentionally unlogged: this is the steady state for every execution while - # the gate is off, so a log here would be pure noise. - if not settings.PG_QUEUE_TRANSPORT_ENABLED: - return celery - - # Gate is ON (canary/rollout). From here the decision is logged so a - # "gate on but everything still Celery" situation cannot hide. - # No org context → per-org segment matching can't be trusted (str(None) would # ship a bogus "None" org into the Flipt context and mis-segment). The view # path validates the header non-None, but the helper path reads it from @@ -107,12 +94,12 @@ def resolve_transport( # FliptClient returns False for ALL flags when the service is marked # unavailable — indistinguishable from "rollout says no". Surface it loudly so - # a blind Flipt under an ON gate doesn't masquerade as a healthy 100%-Celery - # canary. Parse exactly as FliptClient does (``.lower()``, no ``.strip()``) - # so the two can never disagree on a value like ``" true"``. + # a blind Flipt doesn't masquerade as a healthy 100%-Celery rollout. Parse + # exactly as FliptClient does (``.lower()``, no ``.strip()``) so the two can + # never disagree on a value like ``" true"``. if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": logger.warning( - "resolve_transport: gate ON but FLIPT_SERVICE_AVAILABLE != true " + "resolve_transport: FLIPT_SERVICE_AVAILABLE != true " "(Flipt is blind) for execution %s; forcing celery", execution_id, ) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0dca79ab4c..fffd1d3426 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -553,9 +553,8 @@ services: # still hand off to the Celery executor (tool-execution RPC) / Celery # notifications, so they keep a rabbitmq dependency until the executor and # notifications themselves move to PG. Each maps 1:1 to a future K8s Deployment. - # Executions still route to Celery until the backend gate - # (PG_QUEUE_TRANSPORT_ENABLED) + Flipt flag are flipped — that is the ramp, - # not this change. + # Executions still route to Celery until the single `pg_queue_enabled` Flipt + # flag is ramped — that is the rollout, not this change. # =========================================================================== # Orchestrator consumer for API-deployment executions (async_execute_bin_api). diff --git a/docker/sample.env b/docker/sample.env index 7f114461bd..e07444096e 100644 --- a/docker/sample.env +++ b/docker/sample.env @@ -127,6 +127,6 @@ ENABLE_METRICS=true WORKER_PG_REAPER_INTERVAL_SECONDS=5 # Reaper sweep interval (seconds) # # NOTE: routing executions to PG is a SEPARATE, later step. Running these -# services does NOT move any traffic — the backend gate PG_QUEUE_TRANSPORT_ENABLED -# (in backend/.env, default off) plus the Flipt flag still decide per-execution -# transport, and both stay off until the rollout ramp. +# services does NOT move any traffic — the single `pg_queue_enabled` Flipt flag +# (default off, fail-closed) decides per-execution transport, and stays off until +# the rollout ramp. diff --git a/unstract/flags/src/unstract/flags/feature_flag.py b/unstract/flags/src/unstract/flags/feature_flag.py index f9833c3aa2..05bc5de899 100644 --- a/unstract/flags/src/unstract/flags/feature_flag.py +++ b/unstract/flags/src/unstract/flags/feature_flag.py @@ -44,6 +44,18 @@ def check_feature_flag_status( return bool(result) except Exception: + # A genuine evaluation failure (Flipt unreachable, renamed/deleted flag, + # wrong namespace) otherwise collapses into the same False as a healthy + # "flag off" — log it so a real outage is visible at the decision layer + # rather than silent. Callers fail closed on False either way. warning + + # exc_info (not logger.exception) so a persistently-down Flipt doesn't + # bury every call as a Sentry error. + logger.warning( + "check_feature_flag_status: evaluation failed for flag %r; " + "treating as disabled", + flag_key, + exc_info=True, + ) return False @@ -136,4 +148,12 @@ def check_feature_flag_variant( return result except Exception: + # Same rationale as check_feature_flag_status — surface a genuine + # evaluation failure instead of silently returning the disabled default. + logger.warning( + "check_feature_flag_variant: evaluation failed for flag %r; " + "returning disabled default", + flag_key, + exc_info=True, + ) return default_result diff --git a/unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py b/unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py index 5d256a9132..14dcc6e041 100644 --- a/unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py +++ b/unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py @@ -16,10 +16,9 @@ ``dispatch_with_callback`` + the reply_key/timeout orchestration and the never-raises contract (timeout/failure → ``ExecutionResult.failure``). It calls ``transport.enqueue(...)`` and ``transport.wait_for_result(...)``. -- :func:`resolve_pg_transport` — the gate: a master kill-switch (its boolean value - supplied by the caller — a Django setting on the backend, an env var on the - workers) then the single ``pg_queue_enabled`` Flipt flag, bucketed per org. Fails - closed to Celery. +- :func:`resolve_pg_transport` — the gate: the single ``pg_queue_enabled`` Flipt + flag (bucketed per org) is the sole control. Fails closed to Celery on a + blind/unreachable Flipt or any error. - :class:`RoutingExecutionDispatcher` — picks PG-vs-Celery per call (instant rollout/rollback) for every mode; the Celery dispatcher, the PG dispatcher and the per-side ``resolve`` are all injected. @@ -135,22 +134,17 @@ def wait_for_result(self, reply_key: str, timeout: float) -> ExecResultRow | Non def resolve_pg_transport( context: ExecutionContext, *, - master_gate_enabled: bool, flag_key: str = PG_QUEUE_FLAG_KEY, ) -> bool: """True → route this executor dispatch over PG; False → Celery (default). - Master-gated by ``master_gate_enabled`` (the caller supplies its value — a Django - setting on the backend, an env var on the workers), then the single - ``pg_queue_enabled`` Flipt flag, bucketed per org. **Fails closed to Celery** on a - closed gate, a blind Flipt, or any error — so the executor never silently loses - its transport. + Gated by the single ``pg_queue_enabled`` Flipt flag, bucketed per org. + **Fails closed to Celery** on a blind Flipt or any error — so the executor + never silently loses its transport. """ - if not master_gate_enabled: - return False if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": logger.warning( - "resolve_pg_transport: gate ON but FLIPT_SERVICE_AVAILABLE != true " + "resolve_pg_transport: FLIPT_SERVICE_AVAILABLE != true " "(Flipt blind); using Celery" ) return False diff --git a/workers/queue_backend/pg_queue/connection.py b/workers/queue_backend/pg_queue/connection.py index 53c1f414f9..89e5999c5f 100644 --- a/workers/queue_backend/pg_queue/connection.py +++ b/workers/queue_backend/pg_queue/connection.py @@ -16,6 +16,8 @@ import logging import os +import time +from collections.abc import Callable from typing import TYPE_CHECKING import psycopg2 @@ -25,6 +27,68 @@ logger = logging.getLogger(__name__) +# Bounded retry for *transient* connect failures (DB restart, PgBouncer pool +# wait, brief network partition, "too many clients" spikes) — the common cloud +# blips. Opening a connection is side-effect-free, so retrying it is safe and +# purely additive. Note we deliberately do NOT auto-retry the enqueue INSERT +# (queue_backend.pg_queue.client.send): an ambiguous commit-time failure could +# double-enqueue → double-dispatch, so that path stays fail-and-surface. +_DEFAULT_CONNECT_RETRIES = 3 # total attempts (1 = no retry) +_MAX_CONNECT_RETRIES = 10 # sane upper bound — a fat-fingered value can't wedge startup +_DEFAULT_CONNECT_BACKOFF = 0.5 # base seconds between attempts; doubles each retry +_CONNECT_BACKOFF_CAP = ( + 5.0 # fixed ceiling on a single inter-attempt sleep (not env-tunable) +) + + +def _connect_env[T](suffix: str, default: T, cast: Callable[[str], T]) -> T: + """Read ``WORKER_PG_QUEUE_CONNECT_{suffix}``; fall back to ``default``. + + Empty/unset → default; an unparseable value warns and uses the default + (a bad knob must not wedge the only direct-DB worker path). + """ + raw = os.getenv(f"WORKER_PG_QUEUE_CONNECT_{suffix}") + if raw is None or raw == "": + return default + try: + return cast(raw) + except (TypeError, ValueError): + logger.warning( + "PG-queue: invalid WORKER_PG_QUEUE_CONNECT_%s=%r; using default %r", + suffix, + raw, + default, + ) + return default + + +def _connect_retry_policy() -> tuple[int, float]: + """Resolve ``(attempts, base_backoff)`` from env, clamping out-of-range values. + + Out-of-range knobs are clamped *and warned* (not silently coerced), mirroring + the unparseable-value path: attempts to ``[1, _MAX_CONNECT_RETRIES]``, backoff + to ``>= 0``. The per-sleep ``_CONNECT_BACKOFF_CAP`` is a separate fixed ceiling. + """ + raw_retries = _connect_env("RETRIES", _DEFAULT_CONNECT_RETRIES, int) + attempts = min(_MAX_CONNECT_RETRIES, max(1, raw_retries)) + if attempts != raw_retries: + logger.warning( + "PG-queue: WORKER_PG_QUEUE_CONNECT_RETRIES=%d out of range [1, %d]; " + "clamped to %d", + raw_retries, + _MAX_CONNECT_RETRIES, + attempts, + ) + raw_backoff = _connect_env("BACKOFF", _DEFAULT_CONNECT_BACKOFF, float) + backoff = max(0.0, raw_backoff) + if backoff != raw_backoff: + logger.warning( + "PG-queue: WORKER_PG_QUEUE_CONNECT_BACKOFF=%s is negative; clamped to " + "0.0 (retry without sleep)", + raw_backoff, + ) + return attempts, backoff + def create_pg_connection(env_prefix: str = "DB_") -> PgConnection: """Open a direct Postgres connection from ``{env_prefix}*`` env. @@ -37,31 +101,58 @@ def create_pg_connection(env_prefix: str = "DB_") -> PgConnection: is the compose service name); real deployments always set the env explicitly, so the fallback host intentionally differs from the backend's own ``base.py`` default. + + A transient ``OperationalError`` is retried with exponential backoff + (``WORKER_PG_QUEUE_CONNECT_RETRIES`` total attempts, base + ``WORKER_PG_QUEUE_CONNECT_BACKOFF`` seconds, each sleep capped at + ``_CONNECT_BACKOFF_CAP``). Non-operational errors (e.g. a bad ``options`` + string) are not transient and raise immediately. **Permanent** misconfigs + (auth failure, unknown database, bad host) also surface as ``OperationalError``, + so they are retried-then-raised — the final ``logger.error`` makes them obvious. """ host = os.getenv(f"{env_prefix}HOST", "unstract-db") port = os.getenv(f"{env_prefix}PORT", "5432") dbname = os.getenv(f"{env_prefix}NAME", "unstract_db") user = os.getenv(f"{env_prefix}USER", "unstract_dev") schema = os.getenv(f"{env_prefix}SCHEMA", "unstract") - try: - return psycopg2.connect( - host=host, - port=port, - dbname=dbname, - user=user, - password=os.getenv(f"{env_prefix}PASSWORD", "unstract_pass"), - options=f"-c search_path={schema}", - ) - except psycopg2.OperationalError: - # First direct-DB worker component — make the failure self-identifying - # so a misconfigured DB_* var is obvious. Secrets (password) omitted. - logger.error( - "PG-queue: failed to connect to Postgres " - "(host=%s port=%s dbname=%s schema=%s, env_prefix=%r)", - host, - port, - dbname, - schema, - env_prefix, - ) - raise + + attempts, backoff = _connect_retry_policy() + + for attempt in range(1, attempts + 1): + try: + return psycopg2.connect( + host=host, + port=port, + dbname=dbname, + user=user, + password=os.getenv(f"{env_prefix}PASSWORD", "unstract_pass"), + options=f"-c search_path={schema}", + ) + except psycopg2.OperationalError: + if attempt >= attempts: + # Every attempt exhausted — keep the failure self-identifying so a + # misconfigured DB_* var is obvious. Secrets (password) omitted. + logger.error( + "PG-queue: failed to connect to Postgres after %d attempt(s) " + "(host=%s port=%s dbname=%s schema=%s, env_prefix=%r)", + attempts, + host, + port, + dbname, + schema, + env_prefix, + ) + raise + sleep_for = min(backoff * (2 ** (attempt - 1)), _CONNECT_BACKOFF_CAP) + logger.warning( + "PG-queue: connect attempt %d/%d failed " + "(host=%s port=%s dbname=%s schema=%s); retrying in %.2fs", + attempt, + attempts, + host, + port, + dbname, + schema, + sleep_for, + ) + time.sleep(sleep_for) diff --git a/workers/queue_backend/pg_queue/executor_rpc.py b/workers/queue_backend/pg_queue/executor_rpc.py index 1b3367bb03..892866a18b 100644 --- a/workers/queue_backend/pg_queue/executor_rpc.py +++ b/workers/queue_backend/pg_queue/executor_rpc.py @@ -4,18 +4,16 @@ ``unstract.workflow_execution.executor_rpc`` (shared with the backend). This module is the thin psycopg2 half: a :class:`PgClientQueueTransport` that enqueues via :class:`~queue_backend.pg_queue.client.PgQueueClient` and polls via -:class:`~queue_backend.pg_queue.result_backend.PgResultBackend`, plus the per-side -gate (master switch = the ``PG_QUEUE_TRANSPORT_ENABLED`` env, the workers analogue of -the backend's Django setting) and the :func:`get_executor_dispatcher` factory. +:class:`~queue_backend.pg_queue.result_backend.PgResultBackend`, plus the +:func:`get_executor_dispatcher` factory. -Zero-regression: gate off ⇒ the routing dispatcher delegates every mode to the -unchanged Celery ``ExecutionDispatcher`` and no ``pg_task_result`` row is created. +Zero-regression: with the ``pg_queue_enabled`` Flipt flag off the routing dispatcher +delegates every mode to the unchanged Celery ``ExecutionDispatcher`` and no +``pg_task_result`` row is created. """ from __future__ import annotations -import logging -import os from typing import TYPE_CHECKING from unstract.sdk1.execution.dispatcher import ExecutionDispatcher @@ -46,33 +44,13 @@ "resolve_executor_transport", ] -logger = logging.getLogger(__name__) - -# Master kill-switch + deploy-ordering gate — the workers analogue of the backend's -# ``settings.PG_QUEUE_TRANSPORT_ENABLED``, read straight from the env here. -_MASTER_GATE_ENV = "PG_QUEUE_TRANSPORT_ENABLED" -_TRUE = "true" -_FALSE = "false" - def resolve_executor_transport(context: ExecutionContext) -> bool: """True → route this executor dispatch over PG; False → Celery (default). - The workers gate: master switch = the ``PG_QUEUE_TRANSPORT_ENABLED`` env, then the - shared Flipt eval (single ``pg_queue_enabled`` flag, fail-closed). + The single ``pg_queue_enabled`` Flipt flag (fail-closed). """ - raw = os.environ.get(_MASTER_GATE_ENV, _FALSE) - master = raw.strip().lower() == _TRUE - if not master and raw.strip().lower() != _FALSE: - # A fat-fingered value ("1"/"yes"/"on"/" True ") parses to OFF — warn so it - # isn't a silent no-op for an operator who expected it to enable PG. - logger.warning( - "resolve_executor_transport: %s=%r is not 'true'/'false' — treating as " - "OFF (PG transport disabled); use exactly 'true' to enable", - _MASTER_GATE_ENV, - raw, - ) - return resolve_pg_transport(context, master_gate_enabled=master) + return resolve_pg_transport(context) class PgClientQueueTransport(QueueTransport): diff --git a/workers/sample.env b/workers/sample.env index d5564536ce..2df037e51a 100644 --- a/workers/sample.env +++ b/workers/sample.env @@ -474,14 +474,6 @@ EVALUATION_SERVER_IP=unstract-flipt EVALUATION_SERVER_PORT=9005 PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python -# PG-queue transport master-gate (kill-switch), worker side. Mirrors the backend's -# PG_QUEUE_TRANSPORT_ENABLED: the file_processing worker reads it to route the -# structure_tool executor RPC onto the PG queue. Routing onto PG requires ALL -# THREE: this gate True, FLIPT_SERVICE_AVAILABLE=True, and the Flipt flag -# `pg_queue_enabled` on. Keep False until the worker-pg-executor consumer is -# running in the fleet; set False for instant rollback. -PG_QUEUE_TRANSPORT_ENABLED=False - # PG-queue consumer prefork concurrency (UN-3606). >1 makes the consumer launcher # fork N isolated consumer children — the PG analogue of Celery's prefork # --concurrency, so file batches run in parallel. SKIP LOCKED distributes work @@ -490,3 +482,10 @@ PG_QUEUE_TRANSPORT_ENABLED=False # (k8s HPA scales replicas). 1 = single process (default). Set per consumer; for # the file-processing consumer it mirrors WORKER_FILE_PROCESSING_CONCURRENCY. # WORKER_PG_QUEUE_CONSUMER_CONCURRENCY=1 + +# PG-queue direct-connect retry (UN-3619). create_pg_connection retries a transient +# OperationalError (DB restart, PgBouncer pool wait, brief network blip) with +# exponential backoff. RETRIES = total attempts, clamped to [1, 10] (1 = no retry); +# 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 diff --git a/workers/tests/test_executor_rpc.py b/workers/tests/test_executor_rpc.py index 75ed262a58..e2bec43a62 100644 --- a/workers/tests/test_executor_rpc.py +++ b/workers/tests/test_executor_rpc.py @@ -180,42 +180,37 @@ def test_dispatch_with_callback_defaults_task_id(self): assert t.enqueue_calls[0]["task_id"] == handle.id -# --- Shared gate: resolve_pg_transport (master-gated, then Flipt, fail-closed) --- +# --- Shared gate: resolve_pg_transport (single Flipt flag, fail-closed) --- class TestResolvePgTransport: - def test_master_gate_off_is_celery(self): - with patch(f"{_SMOD}.check_feature_flag_status") as flag: - assert resolve_pg_transport(_ctx(), master_gate_enabled=False) is False - flag.assert_not_called() - def test_flipt_unavailable_is_celery(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") with patch(f"{_SMOD}.check_feature_flag_status") as flag: - assert resolve_pg_transport(_ctx(), master_gate_enabled=True) is False + assert resolve_pg_transport(_ctx()) is False flag.assert_not_called() def test_flag_true_is_pg_keyed_on_org(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") with patch(f"{_SMOD}.check_feature_flag_status", return_value=True) as flag: - assert resolve_pg_transport(_ctx("orgX"), master_gate_enabled=True) is True + assert resolve_pg_transport(_ctx("orgX")) is True assert flag.call_args.kwargs["entity_id"] == "orgX" assert flag.call_args.kwargs["flag_key"] == "pg_queue_enabled" def test_flag_false_is_celery(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") with patch(f"{_SMOD}.check_feature_flag_status", return_value=False): - assert resolve_pg_transport(_ctx(), master_gate_enabled=True) is False + assert resolve_pg_transport(_ctx()) is False def test_flipt_error_fails_closed(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") with patch(f"{_SMOD}.check_feature_flag_status", side_effect=RuntimeError("x")): - assert resolve_pg_transport(_ctx(), master_gate_enabled=True) is False + assert resolve_pg_transport(_ctx()) is False def test_org_less_context_buckets_on_run_id(self, monkeypatch): monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "true") with patch(f"{_SMOD}.check_feature_flag_status", return_value=True) as flag: - assert resolve_pg_transport(_ctx(org=None), master_gate_enabled=True) is True + assert resolve_pg_transport(_ctx(org=None)) is True assert flag.call_args.kwargs["entity_id"] == "run-1" assert "organization_id" not in flag.call_args.kwargs["context"] @@ -328,23 +323,22 @@ def test_wait_for_result_none_passes_through(self): with patch(f"{_WMOD}.PgResultBackend", return_value=rb): assert PgClientQueueTransport().wait_for_result("rk", 5) is None - def test_resolve_reads_env_master_gate(self, monkeypatch): - monkeypatch.setenv("PG_QUEUE_TRANSPORT_ENABLED", "true") + def test_resolve_delegates_to_shared_flipt_resolver_true(self): with patch(f"{_WMOD}.resolve_pg_transport", return_value=True) as r: assert resolve_executor_transport(self._ctx()) is True - assert r.call_args.kwargs["master_gate_enabled"] is True + r.assert_called_once() + # No env master-gate is threaded any more — Flipt is the sole gate. + assert "master_gate_enabled" not in r.call_args.kwargs - def test_resolve_env_off_is_false(self, monkeypatch): - monkeypatch.delenv("PG_QUEUE_TRANSPORT_ENABLED", raising=False) - with patch(f"{_WMOD}.resolve_pg_transport", return_value=False) as r: - resolve_executor_transport(self._ctx()) - assert r.call_args.kwargs["master_gate_enabled"] is False + def test_resolve_delegates_to_shared_flipt_resolver_false(self): + with patch(f"{_WMOD}.resolve_pg_transport", return_value=False): + assert resolve_executor_transport(self._ctx()) is False def test_factory_wires_routing_with_workers_transport(self): d = get_executor_dispatcher(celery_app="app") assert isinstance(d, RoutingExecutionDispatcher) # The PG dispatcher is wired with the workers psycopg2 transport, and the gate - # is the workers env-master-gate resolver. + # is the workers' Flipt resolver. assert isinstance(d._pg._transport, PgClientQueueTransport) assert d._resolve is resolve_executor_transport diff --git a/workers/tests/test_pg_connection.py b/workers/tests/test_pg_connection.py new file mode 100644 index 0000000000..b9b619141c --- /dev/null +++ b/workers/tests/test_pg_connection.py @@ -0,0 +1,210 @@ +"""Tests for ``create_pg_connection``'s bounded connect-retry. + +Connecting is side-effect-free, so a *transient* ``OperationalError`` is +retried with exponential backoff (the common cloud blip: DB restart, +PgBouncer pool wait, brief network partition). Non-transient errors raise +immediately, and the enqueue INSERT is intentionally NOT retried elsewhere +(double-dispatch risk) — these tests pin the connect-layer contract only. + +``psycopg2.connect`` and ``time.sleep`` are patched on the module, so no DB +and no real waiting. +""" + +from __future__ import annotations + +import psycopg2 +import pytest +from queue_backend.pg_queue import connection as conn_mod + + +def _patch(monkeypatch, *, connect, retries="3", backoff="0.5"): + """Patch connect + sleep; return the list that records sleep durations.""" + monkeypatch.setattr(conn_mod.psycopg2, "connect", connect) + sleeps: list[float] = [] + monkeypatch.setattr(conn_mod.time, "sleep", lambda s: sleeps.append(s)) + monkeypatch.setenv("WORKER_PG_QUEUE_CONNECT_RETRIES", retries) + monkeypatch.setenv("WORKER_PG_QUEUE_CONNECT_BACKOFF", backoff) + return sleeps + + +class TestCreatePgConnectionRetry: + def test_succeeds_first_try_without_sleeping(self, monkeypatch): + sentinel = object() + calls: list[int] = [] + + def connect(**_kwargs): + calls.append(1) + return sentinel + + sleeps = _patch(monkeypatch, connect=connect) + assert conn_mod.create_pg_connection() is sentinel + assert len(calls) == 1 + assert sleeps == [] + + def test_retries_transient_then_succeeds(self, monkeypatch): + sentinel = object() + seq: list = [ + psycopg2.OperationalError("nope"), + psycopg2.OperationalError("nope"), + sentinel, + ] + + def connect(**_kwargs): + item = seq.pop(0) + if isinstance(item, Exception): + raise item + return item + + sleeps = _patch(monkeypatch, connect=connect) + assert conn_mod.create_pg_connection() is sentinel + # Slept before attempts 2 and 3 with exponential backoff (0.5, 1.0). + assert sleeps == [0.5, 1.0] + + def test_exhausts_attempts_and_raises(self, monkeypatch): + calls: list[int] = [] + + def connect(**_kwargs): + calls.append(1) + raise psycopg2.OperationalError("down") + + sleeps = _patch(monkeypatch, connect=connect, retries="3", backoff="0.1") + with pytest.raises(psycopg2.OperationalError): + conn_mod.create_pg_connection() + assert len(calls) == 3 # all attempts used + assert len(sleeps) == 2 # no sleep after the final attempt + + def test_non_operational_error_is_not_retried(self, monkeypatch): + calls: list[int] = [] + + def connect(**_kwargs): + calls.append(1) + raise psycopg2.ProgrammingError("bad options string") + + sleeps = _patch(monkeypatch, connect=connect) + with pytest.raises(psycopg2.ProgrammingError): + conn_mod.create_pg_connection() + assert len(calls) == 1 # raised on the first attempt + assert sleeps == [] + + def test_single_attempt_when_retries_is_one(self, monkeypatch): + calls: list[int] = [] + + def connect(**_kwargs): + calls.append(1) + raise psycopg2.OperationalError("down") + + sleeps = _patch(monkeypatch, connect=connect, retries="1") + with pytest.raises(psycopg2.OperationalError): + conn_mod.create_pg_connection() + assert len(calls) == 1 + assert sleeps == [] + + def test_invalid_retries_env_falls_back_to_default(self, monkeypatch): + calls: list[int] = [] + + def connect(**_kwargs): + calls.append(1) + raise psycopg2.OperationalError("down") + + # Garbage RETRIES → default of 3 attempts (a bad knob must not wedge it). + sleeps = _patch(monkeypatch, connect=connect, retries="abc", backoff="0.1") + with pytest.raises(psycopg2.OperationalError): + conn_mod.create_pg_connection() + assert len(calls) == 3 + assert len(sleeps) == 2 + + def test_unset_env_uses_production_defaults(self, monkeypatch): + # The _patch helper always sets both knobs; this drives the real default + # path (env absent → 3 attempts / 0.5s base) that production actually uses. + monkeypatch.delenv("WORKER_PG_QUEUE_CONNECT_RETRIES", raising=False) + monkeypatch.delenv("WORKER_PG_QUEUE_CONNECT_BACKOFF", raising=False) + + def connect(**_kwargs): + raise psycopg2.OperationalError("down") + + monkeypatch.setattr(conn_mod.psycopg2, "connect", connect) + sleeps: list[float] = [] + monkeypatch.setattr(conn_mod.time, "sleep", lambda s: sleeps.append(s)) + with pytest.raises(psycopg2.OperationalError): + conn_mod.create_pg_connection() + assert sleeps == [0.5, 1.0] # default base 0.5, 3 attempts → 2 sleeps + + def test_backoff_grows_geometrically(self, monkeypatch): + def connect(**_kwargs): + raise psycopg2.OperationalError("down") + + # 4 attempts, base 1 → [1, 2, 4] — pins geometric (not linear) growth. + sleeps = _patch(monkeypatch, connect=connect, retries="4", backoff="1") + with pytest.raises(psycopg2.OperationalError): + conn_mod.create_pg_connection() + assert sleeps == [1.0, 2.0, 4.0] + + def test_sleep_is_capped_at_backoff_cap(self, monkeypatch): + def connect(**_kwargs): + raise psycopg2.OperationalError("down") + + # base 4 doubles past the 5.0s ceiling: 4, 8→5, 16→5, 32→5. + sleeps = _patch(monkeypatch, connect=connect, retries="5", backoff="4") + with pytest.raises(psycopg2.OperationalError): + conn_mod.create_pg_connection() + assert sleeps == [4.0, 5.0, 5.0, 5.0] + + def test_negative_backoff_clamped_to_zero(self, monkeypatch): + def connect(**_kwargs): + raise psycopg2.OperationalError("down") + + # Negative base → clamped to 0.0 (retry without sleep), not silently kept. + sleeps = _patch(monkeypatch, connect=connect, retries="3", backoff="-1") + with pytest.raises(psycopg2.OperationalError): + conn_mod.create_pg_connection() + assert sleeps == [0.0, 0.0] + + def test_invalid_backoff_env_falls_back_to_default(self, monkeypatch): + def connect(**_kwargs): + raise psycopg2.OperationalError("down") + + sleeps = _patch(monkeypatch, connect=connect, retries="3", backoff="xyz") + with pytest.raises(psycopg2.OperationalError): + conn_mod.create_pg_connection() + assert sleeps == [0.5, 1.0] # default base 0.5 + + def test_retries_above_max_is_clamped(self, monkeypatch): + calls: list[int] = [] + + def connect(**_kwargs): + calls.append(1) + raise psycopg2.OperationalError("down") + + # 100 attempts requested → clamped to the 10-attempt ceiling (9 sleeps). + sleeps = _patch(monkeypatch, connect=connect, retries="100", backoff="0.01") + with pytest.raises(psycopg2.OperationalError): + conn_mod.create_pg_connection() + assert len(calls) == 10 + assert len(sleeps) == 9 + + def test_connection_params_forwarded_and_stable_across_retries(self, monkeypatch): + monkeypatch.setenv("DB_HOST", "pg.test") + monkeypatch.setenv("DB_NAME", "mydb") + monkeypatch.setenv("DB_SCHEMA", "myschema") + sentinel = object() + seq: list = [ + psycopg2.OperationalError("x"), + psycopg2.OperationalError("x"), + sentinel, + ] + calls: list[dict] = [] + + def connect(**kwargs): + calls.append(kwargs) + item = seq.pop(0) + if isinstance(item, Exception): + raise item + return item + + _patch(monkeypatch, connect=connect) + assert conn_mod.create_pg_connection() is sentinel + assert len(calls) == 3 + # The DB_* params (incl. the search_path options) are forwarded and + # identical on every retry. + assert all(c["options"] == "-c search_path=myschema" for c in calls) + assert all(c["host"] == "pg.test" and c["dbname"] == "mydb" for c in calls) From d7b27ceb74c6d27a9289a947267399cac13cd474 Mon Sep 17 00:00:00 2001 From: ali Date: Fri, 26 Jun 2026 17:02:13 +0530 Subject: [PATCH 41/65] =?UTF-8?q?UN-3445=20[FIX]=20workflow=5Fv2=20?= =?UTF-8?q?=E2=80=94=20merge=20migration=20after=20main=20backmerge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backmerge brought workflow_v2.0021_alter_workflow_organization (main) alongside our 0020_workflowexecution_queue_message_id; both fork from 0019, leaving two leaf nodes → 'Conflicting migrations detected' on migrate. Add the standard no-op merge migration 0022 depending on both leaves. detect_conflicts now clean. Co-Authored-By: Claude Opus 4.8 --- .../migrations/0022_merge_20260626_1131.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 backend/workflow_manager/workflow_v2/migrations/0022_merge_20260626_1131.py diff --git a/backend/workflow_manager/workflow_v2/migrations/0022_merge_20260626_1131.py b/backend/workflow_manager/workflow_v2/migrations/0022_merge_20260626_1131.py new file mode 100644 index 0000000000..9e463e8356 --- /dev/null +++ b/backend/workflow_manager/workflow_v2/migrations/0022_merge_20260626_1131.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.1 on 2026-06-26 11:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow_v2", "0020_workflowexecution_queue_message_id"), + ("workflow_v2", "0021_alter_workflow_organization"), + ] + + operations = [] From 163355221648b17d511d1daec59f98148dd799ef Mon Sep 17 00:00:00 2001 From: ali Date: Sat, 27 Jun 2026 10:17:56 +0530 Subject: [PATCH 42/65] =?UTF-8?q?UN-3624=20[FIX]=20PG=20queue=20=E2=80=94?= =?UTF-8?q?=20schema-qualify=20worker=20SQL=20for=20PgBouncer=20txn=20pool?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PG-queue workers connect through PgBouncer's transaction-pool alias (unstract_txn), where the `options=-c search_path=` startup parameter is stripped (IGNORE_STARTUP_PARAMETERS=options). Bare table names then resolve to the default (public) path -> `relation "pg_queue_message" does not exist`. This works in OSS/local (direct Postgres, search_path honoured) but breaks in cloud. Fix: name every queue table explicitly as `"".` via a new `qualified()` helper (schema read lazily from DB_SCHEMA, validated), so the SQL is self-contained and resolves through transaction pooling without search_path — the same way the Celery result backend reaches its public tables. Keeps the unstract_txn transaction pool (no session-pooling / scale regression). - new schema.py: qualified() + queue_schema() + QUEUE_TABLES registry (rejects unknown / typo'd table names — single source of truth) - qualify the SQL in client, result_backend, reaper, leader_election, pg_scheduler, pg_barrier (24 sites) - guard test fails the build on any bare unqualified FROM/INTO/UPDATE pg_
, so a future query cannot silently regress the cloud path Co-Authored-By: Claude Opus 4.8 --- workers/queue_backend/pg_barrier.py | 20 ++-- workers/queue_backend/pg_queue/client.py | 45 +++++--- .../queue_backend/pg_queue/leader_election.py | 8 +- .../queue_backend/pg_queue/pg_scheduler.py | 22 ++-- workers/queue_backend/pg_queue/reaper.py | 16 ++- .../queue_backend/pg_queue/result_backend.py | 32 ++++-- workers/queue_backend/pg_queue/schema.py | 86 +++++++++++++++ workers/tests/test_pg_queue_client.py | 15 +-- workers/tests/test_pg_reaper.py | 28 +++-- workers/tests/test_pg_schema.py | 103 ++++++++++++++++++ 10 files changed, 310 insertions(+), 65 deletions(-) create mode 100644 workers/queue_backend/pg_queue/schema.py create mode 100644 workers/tests/test_pg_schema.py diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 19e80f29a1..f8951f779d 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -83,6 +83,7 @@ ) from .handle import BarrierHandle from .pg_queue.connection import create_pg_connection +from .pg_queue.schema import qualified if TYPE_CHECKING: from celery.canvas import Signature @@ -130,7 +131,8 @@ def _cursor() -> Iterator[Any]: def _delete_barrier(execution_id: str) -> None: with _cursor() as cur: cur.execute( - "DELETE FROM pg_barrier_state WHERE execution_id = %s", (execution_id,) + f"DELETE FROM {qualified('pg_barrier_state')} WHERE execution_id = %s", + (execution_id,), ) @@ -154,7 +156,8 @@ def claim_batch(execution_id: str, batch_index: int) -> bool: """ with _cursor() as cur: cur.execute( - "INSERT INTO pg_batch_dedup (execution_id, batch_index, created_at) " + f"INSERT INTO {qualified('pg_batch_dedup')} " + "(execution_id, batch_index, created_at) " "VALUES (%s, %s, now()) " "ON CONFLICT (execution_id, batch_index) DO NOTHING " "RETURNING execution_id", @@ -178,7 +181,10 @@ def clear_execution_batches(execution_id: str) -> int: Called from the barrier-finalise + abort paths. """ with _cursor() as cur: - cur.execute("DELETE FROM pg_batch_dedup WHERE execution_id = %s", (execution_id,)) + cur.execute( + f"DELETE FROM {qualified('pg_batch_dedup')} WHERE execution_id = %s", + (execution_id,), + ) return cur.rowcount @@ -312,7 +318,7 @@ def enqueue( # concurrent enqueues; orphan reclaim is a separate (future) # periodic sweep keyed on pg_barrier_expires_idx. cur.execute( - "INSERT INTO pg_barrier_state " + f"INSERT INTO {qualified('pg_barrier_state')} " "(execution_id, organization_id, remaining, results, " " created_at, expires_at) " "VALUES (%s, %s, %s, '[]'::jsonb, now(), " @@ -329,7 +335,7 @@ def enqueue( # returns False → all batches skip → barrier hangs to expiry. # (Markers are written by claim_batch() on the in-body PG path.) cur.execute( - "DELETE FROM pg_batch_dedup WHERE execution_id = %s", + f"DELETE FROM {qualified('pg_batch_dedup')} WHERE execution_id = %s", (execution_id,), ) @@ -578,7 +584,7 @@ def _barrier_pg_decrement( try: with _cursor() as cur: cur.execute( - "UPDATE pg_barrier_state " + f"UPDATE {qualified('pg_barrier_state')} " " SET remaining = remaining - 1, " " results = results || jsonb_build_array(%s::jsonb) " " WHERE execution_id = %s " @@ -718,7 +724,7 @@ def barrier_pg_abort( with _cursor() as cur: cur.execute( "WITH claimed AS (" - " DELETE FROM pg_barrier_state WHERE execution_id = %s " + f" DELETE FROM {qualified('pg_barrier_state')} WHERE execution_id = %s " " RETURNING execution_id" ") SELECT execution_id FROM claimed", (execution_id,), diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py index 769ae0b666..05832a1487 100644 --- a/workers/queue_backend/pg_queue/client.py +++ b/workers/queue_backend/pg_queue/client.py @@ -37,12 +37,14 @@ from ..fairness import DEFAULT_PRIORITY, MAX_PRIORITY, MIN_PRIORITY from .connection import create_pg_connection +from .schema import qualified if TYPE_CHECKING: from psycopg2.extensions import connection as PgConnection logger = logging.getLogger(__name__) + # Atomic claim. Takes up to %(qty)s ready messages no other transaction # holds, makes them invisible for %(vt)s seconds, returns them. SKIP LOCKED # => concurrent readers never claim the same visible row (no concurrent @@ -78,17 +80,27 @@ # ``vt <= now()`` applied as a per-row filter — not a guaranteed top-N: vt is # not in the index, so claimed-but-unacked rows (future vt) at the front of a # priority band are scanned past on each claim. Cheap at low in-flight depth. -_DEQUEUE_SQL = """ +def _dequeue_sql() -> str: + """Build the atomic-claim SQL, schema-qualifying ``pg_queue_message``. + + Built per call (not a module constant) so the schema is resolved from the + live ``DB_SCHEMA`` — the table is named ``"".pg_queue_message`` so + it resolves through PgBouncer transaction pooling without ``search_path`` + (see :mod:`queue_backend.pg_queue.schema`). Cost is a trivial f-string vs. + the DB round trip that follows. + """ + msg = qualified("pg_queue_message") + return f""" WITH locked AS ( SELECT msg_id - FROM pg_queue_message + FROM {msg} WHERE queue_name = %s AND vt <= now() ORDER BY priority DESC, msg_id FOR UPDATE SKIP LOCKED LIMIT %s ), claimed AS ( - UPDATE pg_queue_message q + UPDATE {msg} q SET vt = now() + make_interval(secs => %s), read_ct = read_ct + 1 FROM locked @@ -123,12 +135,16 @@ class QueueMessage: # appends ``RETURNING msg_id``; the PG scheduler (pg_scheduler.py) executes this # verbatim inside its own transaction so the enqueue + next_run advance commit # atomically (it can't call send(), which commits internally). Keep callers in -# sync by sharing this constant rather than copying the SQL. -INSERT_MESSAGE_SQL: str = ( - "INSERT INTO pg_queue_message " - "(queue_name, message, org_id, priority, enqueued_at, vt, read_ct) " - "VALUES (%s, %s::jsonb, %s, %s, now(), now(), 0)" -) +# sync by calling this helper rather than copying the SQL. Built per call so +# ``pg_queue_message`` is schema-qualified from the live ``DB_SCHEMA`` (resolves +# through PgBouncer txn pooling without ``search_path`` — see +# :mod:`queue_backend.pg_queue.schema`). +def insert_message_sql() -> str: + return ( + f"INSERT INTO {qualified('pg_queue_message')} " + "(queue_name, message, org_id, priority, enqueued_at, vt, read_ct) " + "VALUES (%s, %s::jsonb, %s, %s, now(), now(), 0)" + ) class PgQueueClient: @@ -211,7 +227,7 @@ def send( ) with self._cursor() as cur: cur.execute( - INSERT_MESSAGE_SQL + " RETURNING msg_id", + insert_message_sql() + " RETURNING msg_id", # "" rather than NULL for "no org" — the column is non-null # (string fields shouldn't have two empty values; Django S6553). ( @@ -242,9 +258,9 @@ def read( if qty <= 0: raise ValueError(f"qty must be positive, got {qty}") with self._cursor() as cur: - # Param order matches the %s positions in _DEQUEUE_SQL: + # Param order matches the %s positions in _dequeue_sql(): # queue_name (locked CTE), qty (LIMIT), vt_seconds (UPDATE SET). - cur.execute(_DEQUEUE_SQL, (queue_name, qty, vt_seconds)) + cur.execute(_dequeue_sql(), (queue_name, qty, vt_seconds)) rows = cur.fetchall() return [ QueueMessage(msg_id=int(r[0]), message=r[1], read_ct=int(r[2])) for r in rows @@ -260,7 +276,10 @@ def delete(self, msg_id: int) -> bool: avoids a duplicate warning per double-run. """ with self._cursor() as cur: - cur.execute("DELETE FROM pg_queue_message WHERE msg_id = %s", (msg_id,)) + cur.execute( + f"DELETE FROM {qualified('pg_queue_message')} WHERE msg_id = %s", + (msg_id,), + ) deleted = cur.rowcount if deleted == 0: logger.debug( diff --git a/workers/queue_backend/pg_queue/leader_election.py b/workers/queue_backend/pg_queue/leader_election.py index 0a1d8415d8..403e84e388 100644 --- a/workers/queue_backend/pg_queue/leader_election.py +++ b/workers/queue_backend/pg_queue/leader_election.py @@ -55,6 +55,7 @@ import psycopg2 from .connection import create_pg_connection +from .schema import qualified if TYPE_CHECKING: from psycopg2.extensions import connection as PgConnection @@ -203,7 +204,7 @@ def try_acquire(self) -> bool: """ with self._cursor() as cur: cur.execute( - "UPDATE pg_orchestrator_lock " + f"UPDATE {qualified('pg_orchestrator_lock')} " " SET leader = %s, acquired_at = now() " " WHERE id = 1 " " AND (leader = '' " @@ -227,7 +228,7 @@ def renew(self) -> bool: """ with self._cursor() as cur: cur.execute( - "UPDATE pg_orchestrator_lock SET acquired_at = now() " + f"UPDATE {qualified('pg_orchestrator_lock')} SET acquired_at = now() " "WHERE id = 1 AND leader = %s RETURNING id", (self._worker_id,), ) @@ -250,7 +251,8 @@ def release(self) -> None: """ with self._cursor() as cur: cur.execute( - "UPDATE pg_orchestrator_lock SET leader = '', acquired_at = now() " + f"UPDATE {qualified('pg_orchestrator_lock')} " + "SET leader = '', acquired_at = now() " "WHERE id = 1 AND leader = %s", (self._worker_id,), ) diff --git a/workers/queue_backend/pg_queue/pg_scheduler.py b/workers/queue_backend/pg_queue/pg_scheduler.py index 42755e7b05..6ae64a5a93 100644 --- a/workers/queue_backend/pg_queue/pg_scheduler.py +++ b/workers/queue_backend/pg_queue/pg_scheduler.py @@ -41,7 +41,8 @@ from unstract.core.data_models import TaskPayload from ..fairness import DEFAULT_PRIORITY -from .client import INSERT_MESSAGE_SQL +from .client import insert_message_sql +from .schema import qualified from .task_payload import to_payload if TYPE_CHECKING: @@ -114,7 +115,8 @@ def _quiesce_invalid_cron(conn: PgConnection, schedule: _DueSchedule) -> None: try: with conn.cursor() as cur: cur.execute( - "UPDATE pg_periodic_schedule SET enabled = FALSE WHERE pipeline_id = %s", + f"UPDATE {qualified('pg_periodic_schedule')} " + "SET enabled = FALSE WHERE pipeline_id = %s", (schedule.pipeline_id,), ) conn.commit() @@ -140,10 +142,10 @@ def dispatch_due_schedules(conn: PgConnection) -> int: cur.execute("SELECT now()") base = cur.fetchone()[0] cur.execute( - """ + f""" SELECT pipeline_id, organization_id, workflow_id, pipeline_name, cron_string, next_run_at - FROM pg_periodic_schedule + FROM {qualified('pg_periodic_schedule')} WHERE pg_owned AND enabled AND (next_run_at IS NULL OR next_run_at <= %s) """, @@ -170,8 +172,8 @@ def dispatch_due_schedules(conn: PgConnection) -> int: # next time and do NOT fire (no burst when handed over). with conn.cursor() as cur: cur.execute( - "UPDATE pg_periodic_schedule SET next_run_at = %s " - "WHERE pipeline_id = %s", + f"UPDATE {qualified('pg_periodic_schedule')} " + "SET next_run_at = %s WHERE pipeline_id = %s", (nxt, schedule.pipeline_id), ) conn.commit() @@ -189,11 +191,11 @@ def dispatch_due_schedules(conn: PgConnection) -> int: pipeline_name=schedule.pipeline_name, ) # Enqueue + advance in ONE transaction so a crash between them can't - # re-fire next cycle. INSERT_MESSAGE_SQL is the shared enqueue - # contract from client.py (send() uses the same constant). + # re-fire next cycle. insert_message_sql() is the shared enqueue + # contract from client.py (send() uses the same helper). with conn.cursor() as cur: cur.execute( - INSERT_MESSAGE_SQL, + insert_message_sql(), ( SCHEDULER_QUEUE_NAME, json.dumps(payload), @@ -202,7 +204,7 @@ def dispatch_due_schedules(conn: PgConnection) -> int: ), ) cur.execute( - "UPDATE pg_periodic_schedule " + f"UPDATE {qualified('pg_periodic_schedule')} " "SET last_run_at = %s, next_run_at = %s WHERE pipeline_id = %s", (base, nxt, schedule.pipeline_id), ) diff --git a/workers/queue_backend/pg_queue/reaper.py b/workers/queue_backend/pg_queue/reaper.py index 4a0c79f5f7..5d6f54b794 100644 --- a/workers/queue_backend/pg_queue/reaper.py +++ b/workers/queue_backend/pg_queue/reaper.py @@ -60,6 +60,7 @@ from .leader_election import LeaderLease, default_worker_id from .liveness import LivenessServer as _BaseLivenessServer from .pg_scheduler import dispatch_due_schedules +from .schema import qualified if TYPE_CHECKING: from psycopg2.extensions import connection as PgConnection @@ -212,7 +213,9 @@ def sweep_expired_results(conn: PgConnection) -> int: """ try: with conn.cursor() as cur: - cur.execute("DELETE FROM pg_task_result WHERE expires_at <= now()") + cur.execute( + f"DELETE FROM {qualified('pg_task_result')} WHERE expires_at <= now()" + ) deleted = cur.rowcount conn.commit() return deleted @@ -236,7 +239,7 @@ def sweep_orphan_dedup(conn: PgConnection, retention_seconds: int) -> int: try: with conn.cursor() as cur: cur.execute( - "DELETE FROM pg_batch_dedup " + f"DELETE FROM {qualified('pg_batch_dedup')} " "WHERE created_at <= now() - make_interval(secs => %s)", (retention_seconds,), ) @@ -280,7 +283,7 @@ def _still_expired(conn: PgConnection, execution_id: str) -> bool: """ with conn.cursor() as cur: cur.execute( - "SELECT 1 FROM pg_barrier_state " + f"SELECT 1 FROM {qualified('pg_barrier_state')} " "WHERE execution_id = %s AND expires_at < now()", (execution_id,), ) @@ -401,14 +404,15 @@ def _recover_one_barrier( # Queue-infra cleanup (direct PG), re-guarded against a concurrent re-arm. with conn.cursor() as cur: cur.execute( - "DELETE FROM pg_barrier_state WHERE execution_id = %s " + f"DELETE FROM {qualified('pg_barrier_state')} WHERE execution_id = %s " "AND expires_at < now()", (execution_id,), ) deleted = cur.rowcount > 0 if deleted: cur.execute( - "DELETE FROM pg_batch_dedup WHERE execution_id = %s", (execution_id,) + f"DELETE FROM {qualified('pg_batch_dedup')} WHERE execution_id = %s", + (execution_id,), ) else: logger.warning( @@ -441,7 +445,7 @@ def recover_expired_barriers( with conn.cursor() as cur: cur.execute( "SELECT execution_id, organization_id, remaining " - "FROM pg_barrier_state WHERE expires_at < now()" + f"FROM {qualified('pg_barrier_state')} WHERE expires_at < now()" ) rows = cur.fetchall() conn.commit() diff --git a/workers/queue_backend/pg_queue/result_backend.py b/workers/queue_backend/pg_queue/result_backend.py index e6544afc49..0968c1521e 100644 --- a/workers/queue_backend/pg_queue/result_backend.py +++ b/workers/queue_backend/pg_queue/result_backend.py @@ -41,6 +41,7 @@ from unstract.core.polling import poll_for_row from .connection import create_pg_connection +from .schema import qualified if TYPE_CHECKING: from psycopg2.extensions import connection as PgConnection @@ -52,15 +53,26 @@ # outlives any caller still waiting on it. DEFAULT_RETENTION_SECONDS = 3600 + # First write wins — an at-least-once redelivery of the executor message must -# not overwrite a recorded result. -_STORE_SQL = ( - "INSERT INTO pg_task_result " - "(task_id, status, result, error, created_at, expires_at) " - "VALUES (%s, %s, %s::jsonb, %s, now(), now() + make_interval(secs => %s)) " - "ON CONFLICT (task_id) DO NOTHING" -) -_GET_SQL = "SELECT status, result, error FROM pg_task_result WHERE task_id = %s" +# not overwrite a recorded result. Built per call so ``pg_task_result`` is +# schema-qualified from the live ``DB_SCHEMA`` (resolves through PgBouncer txn +# pooling without ``search_path`` — see :mod:`queue_backend.pg_queue.schema`). +def _store_sql() -> str: + return ( + f"INSERT INTO {qualified('pg_task_result')} " + "(task_id, status, result, error, created_at, expires_at) " + "VALUES (%s, %s, %s::jsonb, %s, now(), now() + make_interval(secs => %s)) " + "ON CONFLICT (task_id) DO NOTHING" + ) + + +def _get_sql() -> str: + return ( + f"SELECT status, result, error FROM {qualified('pg_task_result')} " + "WHERE task_id = %s" + ) + # Poll cadence for wait_for_result: start tight (low latency for fast tasks), # back off to a ceiling so a long-running task doesn't hammer the DB. @@ -137,7 +149,7 @@ def store_result( status, result_json, error_text = STATUS_FAILED, None, error or "" with self._cursor() as cur: cur.execute( - _STORE_SQL, + _store_sql(), (str(task_id), status, result_json, error_text, retention_seconds), ) @@ -148,7 +160,7 @@ def get_result(self, task_id: str) -> dict[str, Any] | None: Python ``dict``); ``None`` means the task has not finished yet. """ with self._cursor() as cur: - cur.execute(_GET_SQL, (str(task_id),)) + cur.execute(_get_sql(), (str(task_id),)) row = cur.fetchone() if row is None: return None diff --git a/workers/queue_backend/pg_queue/schema.py b/workers/queue_backend/pg_queue/schema.py new file mode 100644 index 0000000000..ddb4b69b9c --- /dev/null +++ b/workers/queue_backend/pg_queue/schema.py @@ -0,0 +1,86 @@ +"""Schema-qualified table names for the bespoke PG queue. + +The queue's tables (``pg_queue_message``, ``pg_task_result``, +``pg_barrier_state``, ``pg_batch_dedup``, ``pg_orchestrator_lock``, +``pg_periodic_schedule``) live in the schema the backend manages +(``DB_SCHEMA`` — ``unstract`` on-prem, a per-developer schema such as ``ali`` +in cloud dev). + +**Why the SQL must name the schema explicitly** instead of relying on +``search_path``: in cloud the worker connects through PgBouncer, which strips +the ``options=-c search_path=…`` startup parameter +(``IGNORE_STARTUP_PARAMETERS=options``), so the connection never gets the +queue's schema on its path and bare table names resolve to the default +(``public``) → ``UndefinedTable``. Transaction pooling also wouldn't preserve a +``SET search_path`` between statements. Qualifying every table as +``"".
`` makes the SQL self-contained, so it works through the +PgBouncer **transaction** pool unchanged — the same way the Celery result +backend reaches its ``public`` tables. + +The schema is read at call time (not import) from the same ``{prefix}SCHEMA`` +env the connection uses (:func:`create_pg_connection`), so the qualified name +always matches where the connection looks. +""" + +from __future__ import annotations + +import os +import re + +# DB_SCHEMA is operator-supplied (helm values / env), never user input — but we +# still validate it as a bare SQL identifier before interpolating it into SQL, +# as defence-in-depth so a typo'd/hostile value can't become injection. +_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +# Mirror create_pg_connection's default so an unset DB_SCHEMA qualifies to the +# same schema the connection targets (search_path default ``unstract``). +_DEFAULT_SCHEMA = "unstract" + +# Single source of truth for the bespoke queue tables. Every raw query qualifies +# its table THROUGH :func:`qualified`, which rejects any name not listed here — so +# a typo fails loud, and a genuinely new table forces an explicit addition. The +# no-bare-table guard test (tests/test_pg_schema.py) reads this same set, so the +# registry and the lint stay in lockstep. +QUEUE_TABLES = frozenset( + { + "pg_queue_message", + "pg_task_result", + "pg_barrier_state", + "pg_batch_dedup", + "pg_orchestrator_lock", + "pg_periodic_schedule", + } +) + + +def queue_schema(env_prefix: str = "DB_") -> str: + """Return the validated queue schema from ``{env_prefix}SCHEMA``. + + Raises ``ValueError`` if the configured schema is not a plain SQL + identifier — failing loud at the first query beats silently building + injectable or malformed SQL. + """ + schema = os.getenv(f"{env_prefix}SCHEMA", _DEFAULT_SCHEMA) + if not _IDENT_RE.match(schema): + raise ValueError( + f"PG queue: {env_prefix}SCHEMA={schema!r} is not a valid SQL " + "identifier (expected [A-Za-z_][A-Za-z0-9_]*)" + ) + return schema + + +def qualified(table: str, env_prefix: str = "DB_") -> str: + """Return ``"".
`` for use in the queue's raw SQL. + + ``table`` must be one of :data:`QUEUE_TABLES` — an unknown name (typo, or a + new table not yet registered) raises, so it can never silently produce a + query against the wrong/non-existent relation. Only the schema is quoted (it + can be a reserved word or mixed case); the table names are fixed lowercase + identifiers owned by this module. + """ + if table not in QUEUE_TABLES: + raise ValueError( + f"PG queue: {table!r} is not a known queue table. Add it to " + "QUEUE_TABLES (the single registry) if it is genuinely new." + ) + return f'"{queue_schema(env_prefix)}".{table}' diff --git a/workers/tests/test_pg_queue_client.py b/workers/tests/test_pg_queue_client.py index 4a473af01d..ad348ad58d 100644 --- a/workers/tests/test_pg_queue_client.py +++ b/workers/tests/test_pg_queue_client.py @@ -23,6 +23,7 @@ from queue_backend.fairness import DEFAULT_PRIORITY, MAX_PRIORITY, MIN_PRIORITY from queue_backend.pg_queue import PgQueueClient, QueueMessage from queue_backend.pg_queue.connection import create_pg_connection +from queue_backend.pg_queue.schema import qualified # --- Unit: SQL shape against a mocked connection --- @@ -56,7 +57,7 @@ def test_send_inserts_and_returns_msg_id(self): msg_id = PgQueueClient(conn=conn).send("q1", {"a": 1}, org_id="org-9") assert msg_id == 42 sql, params = cur.execute.call_args.args - assert "INSERT INTO pg_queue_message" in sql + assert f"INSERT INTO {qualified('pg_queue_message')}" in sql assert params[0] == "q1" assert params[2] == "org-9" assert '"a": 1' in params[1] # message JSON-serialised @@ -95,7 +96,7 @@ def test_read_runs_skip_locked_dequeue(self): msgs = PgQueueClient(conn=conn).read("q1", vt_seconds=15, qty=3) sql, params = cur.execute.call_args.args assert "FOR UPDATE SKIP LOCKED" in sql - assert "UPDATE pg_queue_message" in sql + assert f"UPDATE {qualified('pg_queue_message')}" in sql assert "ORDER BY priority DESC" in sql # fairness L3 claim order # Param order follows the %s positions: queue_name, qty, vt_seconds. assert params == ("q1", 3, 15) @@ -106,7 +107,7 @@ def test_delete_returns_true_when_row_removed(self): conn, cur = _mock_conn(rowcount=1) assert PgQueueClient(conn=conn).delete(7) is True sql, params = cur.execute.call_args.args - assert "DELETE FROM pg_queue_message" in sql + assert f"DELETE FROM {qualified('pg_queue_message')}" in sql assert params == (7,) conn.commit.assert_called_once() @@ -145,9 +146,7 @@ def _owned_client(monkeypatch, *, execute_raises=None): conn.closed = 0 conn.cursor.return_value = _CursorCtx(cur) factory = MagicMock(return_value=conn) - monkeypatch.setattr( - "queue_backend.pg_queue.client.create_pg_connection", factory - ) + monkeypatch.setattr("queue_backend.pg_queue.client.create_pg_connection", factory) return PgQueueClient(), conn, factory def test_owned_conn_recovered_on_operational_error(self, monkeypatch): @@ -243,9 +242,7 @@ def test_connect_failure_is_logged_and_reraised(self, monkeypatch, caplog): def boom(**_): raise psycopg2.OperationalError("nope") - monkeypatch.setattr( - "queue_backend.pg_queue.connection.psycopg2.connect", boom - ) + monkeypatch.setattr("queue_backend.pg_queue.connection.psycopg2.connect", boom) with ( caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.connection"), pytest.raises(psycopg2.OperationalError), diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py index 01db9fda9a..d36bd42989 100644 --- a/workers/tests/test_pg_reaper.py +++ b/workers/tests/test_pg_reaper.py @@ -35,6 +35,7 @@ sweep_expired_results, sweep_orphan_dedup, ) +from queue_backend.pg_queue.schema import qualified # The reaper's leader tick also runs the PG scheduler tick (②b). Its behaviour @@ -163,14 +164,18 @@ def test_non_positive_sweep_interval_rejected(self): # An injected knob bypasses the env parser's guard → re-validated in __init__. with pytest.raises(ValueError, match="sweep_interval"): PgReaper( - _FakeLease(), interval_seconds=1, sweep_interval_seconds=0, + _FakeLease(), + interval_seconds=1, + sweep_interval_seconds=0, sweep_conn=object(), ) def test_non_positive_dedup_retention_rejected(self): with pytest.raises(ValueError, match="dedup_retention"): PgReaper( - _FakeLease(), interval_seconds=1, dedup_retention_seconds=0, + _FakeLease(), + interval_seconds=1, + dedup_retention_seconds=0, sweep_conn=object(), ) @@ -264,7 +269,8 @@ def boom(): class TestSchedulerTick: """The orchestrator's second job: the leader (and only the leader) runs the PG scheduler tick each cycle. Scheduling behaviour itself is in - test_pg_scheduler.py; here we only assert the wiring + leader gating.""" + test_pg_scheduler.py; here we only assert the wiring + leader gating. + """ def _reaper(self, lease): return PgReaper( @@ -335,14 +341,17 @@ def test_sweep_expired_results_sql(self): conn, cur = self._conn_cur(3) assert sweep_expired_results(conn) == 3 sql = cur.execute.call_args[0][0] - assert "DELETE FROM pg_task_result" in sql and "expires_at <= now()" in sql + assert ( + f"DELETE FROM {qualified('pg_task_result')}" in sql + and "expires_at <= now()" in sql + ) conn.commit.assert_called_once() def test_sweep_orphan_dedup_sql(self): conn, cur = self._conn_cur(2) assert sweep_orphan_dedup(conn, 999) == 2 args = cur.execute.call_args[0] - assert "DELETE FROM pg_batch_dedup" in args[0] + assert f"DELETE FROM {qualified('pg_batch_dedup')}" in args[0] assert "created_at <= now() - make_interval" in args[0] assert args[1] == (999,) # the retention param is bound, not interpolated conn.commit.assert_called_once() @@ -502,8 +511,13 @@ class _FakeApiClient: """ def __init__( - self, status="EXECUTING", *, fail_read=False, fail_update=False, - fail_update_for=None, on_get=None, + self, + status="EXECUTING", + *, + fail_read=False, + fail_update=False, + fail_update_for=None, + on_get=None, ): self._status = status self._fail_read = fail_read diff --git a/workers/tests/test_pg_schema.py b/workers/tests/test_pg_schema.py new file mode 100644 index 0000000000..22cf0df05d --- /dev/null +++ b/workers/tests/test_pg_schema.py @@ -0,0 +1,103 @@ +"""Unit tests for the PG-queue schema qualifier (queue_backend.pg_queue.schema). + +The qualifier is what lets the worker's raw SQL resolve its tables through +PgBouncer transaction pooling (which strips the ``search_path`` startup param) — +so these guard that the schema is read from ``DB_SCHEMA``, validated, rendered as +a quoted ``"".
`` prefix, and that no production query forgets to +go through it. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from queue_backend.pg_queue.schema import QUEUE_TABLES, qualified, queue_schema + + +class TestQueueSchema: + def test_defaults_to_unstract_when_unset(self, monkeypatch): + monkeypatch.delenv("DB_SCHEMA", raising=False) + assert queue_schema() == "unstract" + + def test_reads_db_schema(self, monkeypatch): + monkeypatch.setenv("DB_SCHEMA", "acme") + assert queue_schema() == "acme" + + def test_honours_env_prefix(self, monkeypatch): + # The integration suite connects via TEST_DB_* — the qualifier must be + # able to follow the same prefix the connection used. + monkeypatch.setenv("TEST_DB_SCHEMA", "test_tenant") + assert queue_schema(env_prefix="TEST_DB_") == "test_tenant" + + @pytest.mark.parametrize( + "bad", ["pg_queue; DROP TABLE x", "a.b", "1schema", "has space", '"quoted"', ""] + ) + def test_rejects_non_identifier_schema(self, monkeypatch, bad): + # Defence-in-depth: an operator typo / hostile value must fail loud, not + # build injectable or malformed SQL. + monkeypatch.setenv("DB_SCHEMA", bad) + with pytest.raises(ValueError, match="not a valid SQL identifier"): + queue_schema() + + +class TestQualified: + def test_quotes_schema_and_appends_table(self, monkeypatch): + monkeypatch.setenv("DB_SCHEMA", "acme") + assert qualified("pg_queue_message") == '"acme".pg_queue_message' + + def test_default_schema(self, monkeypatch): + monkeypatch.delenv("DB_SCHEMA", raising=False) + assert qualified("pg_task_result") == '"unstract".pg_task_result' + + def test_propagates_schema_validation(self, monkeypatch): + monkeypatch.setenv("DB_SCHEMA", "bad;schema") + with pytest.raises(ValueError, match="not a valid SQL identifier"): + qualified("pg_queue_message") + + def test_rejects_unknown_table(self, monkeypatch): + # A typo'd / unregistered table must fail loud rather than build a query + # against a non-existent relation. + monkeypatch.setenv("DB_SCHEMA", "acme") + with pytest.raises(ValueError, match="not a known queue table"): + qualified("pg_queue_mesage") # typo + + def test_every_registered_table_qualifies(self, monkeypatch): + monkeypatch.setenv("DB_SCHEMA", "acme") + for table in QUEUE_TABLES: + assert qualified(table) == f'"acme".{table}' + + +# Production modules that run raw queue SQL. Globbed (not hardcoded) so a NEW +# module under queue_backend/ is automatically covered by the guard below. +_WORKERS_ROOT = Path(__file__).resolve().parent.parent +_PROD_SQL_DIR = _WORKERS_ROOT / "queue_backend" + + +class TestNoBareTableGuard: + """Fail the build if any production query names a queue table WITHOUT going + through :func:`qualified`. + + A bare ``FROM/INTO/UPDATE pg_
`` resolves via ``search_path`` — which + works in OSS/local (direct Postgres) but is stripped by PgBouncer in cloud → + ``UndefinedTable``. So such a slip passes local tests and only breaks in + cloud; this static guard is what makes "forgetting to qualify" un-mergeable. + """ + + def test_no_unqualified_queue_table_reference(self): + # SQL keywords are uppercase by convention here, so the case-sensitive + # match ignores lowercase prose / docstrings. A qualified use reads + # ``FROM {qualified('pg_x')}`` — the table name never follows the keyword + # directly, so only BARE references match. + tables = "|".join(sorted(QUEUE_TABLES)) + bare = re.compile(rf"\b(?:FROM|INTO|UPDATE)\s+(?:{tables})\b") + offenders: list[str] = [] + for path in sorted(_PROD_SQL_DIR.rglob("*.py")): + for m in bare.finditer(path.read_text()): + rel = path.relative_to(_WORKERS_ROOT) + offenders.append(f"{rel}: {m.group(0)!r}") + assert not offenders, ( + "Unqualified queue-table reference(s) — wrap the table with " + "queue_backend.pg_queue.schema.qualified():\n" + "\n".join(offenders) + ) From 2159ffd9f7f5d7c378dc7efbcc6e5b0511d8b5f8 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:22:01 +0530 Subject: [PATCH 43/65] =?UTF-8?q?UN-3651=20[FIX]=20PG=20barrier=20enqueue?= =?UTF-8?q?=20=E2=80=94=20reconnect-retry=20on=20stale=20cached=20connecti?= =?UTF-8?q?on=20(#2121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3651 [FIX] PG barrier enqueue — reconnect-retry on stale cached connection A transient `psycopg2.OperationalError: server closed the connection unexpectedly` during the PgBarrier enqueue (the barrier UPSERT) aborted the whole ETL execution -> status ERROR with 0 files. PgBarrier keeps a thread-local cached connection reused across barrier ops; while idle between ops it can be reaped server-side (PgBouncer server_idle_timeout / DB failover) and `_get_conn` can't tell (conn.closed is client-side only), so the first statement after the idle gap fails. `_cursor` recovered the dead conn but did not retry the op. Fix: a one-shot reconnect-retry (`_run_idempotent_write`, attempts=2) scoped to the IDEMPOTENT, pre-dispatch barrier write only — the UPSERT (ON CONFLICT DO UPDATE -> same row/state) + the per-execution dedup reset. Re-running them after an ambiguous commit is a no-op and no header has been dispatched yet, so a retry can neither duplicate a row nor double-dispatch work. Idempotency boundary preserved: the non-idempotent decrement (remaining - 1) and claim_batch stay on the plain `_cursor` (recover-but-don't-retry) — a re-applied decrement could fire the callback early. Scope: workers only, under the pg_queue_enabled flag (PgBarrier runs only on the PG transport; flag off -> CeleryChordBarrier, this code never runs -> zero Celery-path regression). Tests: +3 unit tests (retry-then-succeed / no-retry-on-non-connection-error / reraise-after-exhaust); full PgBarrier suite 54 passed; ruff clean. Dev-tested live (A/B against real Postgres via pg_terminate_backend): old path raises + loses the write; new path reconnects, retries, and the write lands. Co-Authored-By: Claude Opus 4.8 * UN-3651 [FIX] address PR #2121 review — observability, backoff, naming, tests Review feedback (greptile + PR-review-toolkit) — all non-blocking; fix was confirmed correct. Changes: - Retry log now names the real error (type + message) and keeps the traceback (exc_info=True); reworded so it no longer asserts a connection drop the broad psycopg2 catch can't be sure of. (HIGH — observability) - Add a small fixed backoff (0.5s) before the retry — widens the self-heal window on a brief failover and avoids immediately re-hammering a struggling DB on the server-side errors the broad OperationalError catch also covers. (MED) - Rename _run_idempotent_write -> _run_idempotent_pre_dispatch_write and type the op as Callable[[PgCursor], None] so the idempotent/pre-dispatch contract is loud at the call site (it can't be type-enforced). (MED — type design) - _BARRIER_WRITE_ATTEMPTS: Final = 2 # total attempts; doc/comment precision: decrement hazard reworded (premature/incomplete callback or strand, not "fire twice"), "same state" softened (timestamps refresh harmlessly), trimmed the redundant call-site comment to a one-line pointer. (LOW) - Tests: add an end-to-end self-heal test through enqueue() against the real DB (asserts the row lands once + every header dispatched exactly once — pins the wiring a no-DB test can't); parametrize the retry test over OperationalError AND InterfaceError; assert no partial commit (commits==0), exactly one extra attempt (executes counters), and the on-retry warning via caplog. (HIGH + MED) Full PgBarrier suite: 56 passed; ruff clean. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/queue_backend/pg_barrier.py | 78 ++++++++++++- workers/tests/test_pg_barrier.py | 172 +++++++++++++++++++++++++++- 2 files changed, 244 insertions(+), 6 deletions(-) diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index f8951f779d..235229f528 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -60,9 +60,10 @@ import json import logging import threading +import time from collections.abc import Callable, Iterator from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final import psycopg2 import psycopg2.extensions @@ -88,6 +89,7 @@ if TYPE_CHECKING: from celery.canvas import Signature from psycopg2.extensions import connection as PgConnection + from psycopg2.extensions import cursor as PgCursor logger = logging.getLogger(__name__) @@ -128,6 +130,72 @@ def _cursor() -> Iterator[Any]: raise +# One retry for an IDEMPOTENT, pre-dispatch barrier write. The thread-local +# connection is cached across barrier ops, so it can be reaped server-side +# (PgBouncer server_idle_timeout / DB failover) while sitting idle BETWEEN ops — +# and ``_get_conn`` can't tell, since ``conn.closed`` is a client-side flag only. +# The first statement after the idle gap then fails; ``_cursor`` discards the +# dead conn, so a single retry runs against a freshly reconnected one. This turns +# a transient blip (which previously aborted the whole execution at barrier +# enqueue) into a self-heal. Kept a literal (not env-driven) so the idempotency +# bound can't be weakened operationally. +_BARRIER_WRITE_ATTEMPTS: Final = 2 # total attempts: 1 initial + 1 retry +# Small fixed pause before the retry. The idle-reap case reconnects instantly +# regardless; this only widens the self-heal window for a brief DB failover, and +# avoids immediately re-hammering a struggling server on the rarer server-side +# errors the broad psycopg2.OperationalError catch also covers. +_BARRIER_RETRY_BACKOFF_SECONDS: Final = 0.5 + + +def _run_idempotent_pre_dispatch_write( + operation: Callable[[PgCursor], None], *, what: str +) -> None: + """Run ``operation(cur)`` in a committed cursor, retrying ONCE if the cached + connection was dead. + + The name spells out the contract because it can't be type-enforced: only call + with an **idempotent** statement run **before** any task dispatch — the + barrier UPSERT (``ON CONFLICT … DO UPDATE`` → same row, ``remaining``/ + ``results`` reset identically; timestamps refresh, harmlessly) + the + per-execution dedup reset (``DELETE WHERE execution_id``). Re-running them + after an ambiguous commit is a no-op, so a retry can neither duplicate a row + nor double-dispatch work (no header has been enqueued yet). The ``-> None`` + op signature also blocks passing a ``RETURNING``-reading op by construction. + + Deliberately NOT used for the barrier **decrement** (``remaining = + remaining - 1``): it is not idempotent — a re-applied decrement can fire the + callback **prematurely with incomplete results**, or skip past 0 and + **strand the barrier** to expiry — so it stays on the plain :func:`_cursor` + (recover-but-don't-retry). Same for ``claim_batch``, whose ``RETURNING`` + answer flips on a retry. + """ + for attempt in range(1, _BARRIER_WRITE_ATTEMPTS + 1): + try: + with _cursor() as cur: + operation(cur) + return + except (psycopg2.OperationalError, psycopg2.InterfaceError) as exc: + # _cursor already dropped the dead thread-local conn → the next + # _get_conn() reconnects. Retry once; re-raise if it still fails + # (a genuinely-down DB surfaces as ERROR, as before). Name the real + # error + keep the traceback: the broad catch also covers server-side + # conditions (statement timeout, deadlock, admin shutdown), so the + # message must not assert a connection drop it can't be sure of. + if attempt >= _BARRIER_WRITE_ATTEMPTS: + raise + logger.warning( + "PgBarrier: %s — DB write failed (%s: %s); cached connection " + "likely stale, reconnecting and retrying (attempt %d/%d)", + what, + type(exc).__name__, + exc, + attempt, + _BARRIER_WRITE_ATTEMPTS, + exc_info=True, + ) + time.sleep(_BARRIER_RETRY_BACKOFF_SECONDS) + + def _delete_barrier(execution_id: str) -> None: with _cursor() as cur: cur.execute( @@ -311,7 +379,7 @@ def enqueue( f"could not be reaper-recovered. This is a bug in the caller." ) - with _cursor() as cur: + def _reset_barrier(cur: PgCursor) -> None: # UPSERT clears any leftover state from a prior run with this id. # No inline expiry sweep here — an unbounded global DELETE on the # enqueue hot path risks lock contention / deadlocks between @@ -339,6 +407,12 @@ def enqueue( (execution_id,), ) + # Idempotent + pre-dispatch → safe to retry; see + # _run_idempotent_pre_dispatch_write. + _run_idempotent_pre_dispatch_write( + _reset_barrier, what=f"enqueue exec={execution_id}" + ) + self._dispatch_headers( header_tasks, is_pg=is_pg, diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index a4fca8b146..7d032a3b19 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -43,6 +43,7 @@ def _pg_header(task_name="process_file_batch", args=None, queue="file_processing sig.options = {"queue": queue} return sig + _CALLBACK = { "task_name": "process_batch_callback_api", "kwargs": {"execution_id": "exec-1", "pipeline_id": "pipe-1"}, @@ -115,6 +116,140 @@ def test_missing_execution_id_raises(self): ) +# --- Idempotent-write reconnect-retry (no DB) --- + + +class _FakeCursorCtx: + def __init__(self, on_execute): + self._on_execute = on_execute + + def __enter__(self): + cur = MagicMock(name="cursor") + cur.execute.side_effect = self._on_execute + return cur + + def __exit__(self, *exc): + return False + + +class _FakeConn: + """Minimal psycopg2-connection stub. ``execute_error`` (if set) is raised by + every cursor.execute on this connection — simulating a stale/dead socket. + """ + + def __init__(self, *, execute_error=None): + self.closed = False + self._execute_error = execute_error + self.commits = 0 + self.rollbacks = 0 + self.executes = 0 + + def cursor(self): + def _on_execute(*_a, **_k): + self.executes += 1 + if self._execute_error is not None: + raise self._execute_error + + return _FakeCursorCtx(_on_execute) + + def commit(self): + self.commits += 1 + + def rollback(self): + self.rollbacks += 1 + + def close(self): + self.closed = True + + +@pytest.fixture +def _clean_local(): + """Ensure the module thread-local connection is reset around each test.""" + pg_barrier._local.conn = None + yield + pg_barrier._local.conn = None + + +class TestIdempotentWriteRetry: + """`_run_idempotent_pre_dispatch_write` self-heals a stale cached connection + with ONE retry — the fix for the `server closed the connection unexpectedly` + abort at barrier enqueue. It must retry ONLY on connection errors, stay + bounded, and never silently swallow a real (non-connection) failure. + """ + + @pytest.fixture(autouse=True) + def _no_sleep(self, monkeypatch): + # The retry backoff is real (0.5s); skip the wall-clock wait in tests. + monkeypatch.setattr(pg_barrier.time, "sleep", lambda *_a, **_k: None) + + @pytest.mark.parametrize( + "exc", + [ + psycopg2.OperationalError("server closed the connection unexpectedly"), + psycopg2.InterfaceError("connection already closed"), + ], + ids=["OperationalError", "InterfaceError"], + ) + def test_retries_once_on_dead_connection_then_succeeds( + self, _clean_local, monkeypatch, caplog, exc + ): + # InterfaceError is the more common stale-socket symptom, so both arms of + # the (OperationalError, InterfaceError) catch must self-heal. + dead = _FakeConn(execute_error=exc) + healthy = _FakeConn() + pg_barrier._local.conn = dead + # On reconnect (_cursor discarded the dead conn), hand back the healthy one. + monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: healthy) + + attempts = [] + + def op(cur): + attempts.append(1) + cur.execute("UPSERT ...", ("x",)) + + with caplog.at_level("WARNING"): + pg_barrier._run_idempotent_pre_dispatch_write(op, what="test") + + assert len(attempts) == 2 # first failed mid-execute, retry succeeded + assert dead.executes == 1 and healthy.executes == 1 # exactly one extra try + assert dead.commits == 0 # no partial commit on the failed attempt + assert dead.closed is True # stale conn discarded + assert healthy.commits == 1 # committed exactly once, on the retry + assert pg_barrier._local.conn is healthy + # logs on retry (not on success) and names the real error, not a guess. + assert "reconnecting and retrying" in caplog.text + assert type(exc).__name__ in caplog.text + + def test_does_not_retry_non_connection_error(self, _clean_local, monkeypatch): + healthy = _FakeConn() + pg_barrier._local.conn = healthy + reconnects = [] + monkeypatch.setattr( + pg_barrier, + "create_pg_connection", + lambda **_k: reconnects.append(1) or _FakeConn(), + ) + + def op(cur): + raise ValueError("not a connection problem") + + with pytest.raises(ValueError, match="not a connection problem"): + pg_barrier._run_idempotent_pre_dispatch_write(op, what="test") + assert reconnects == [] # a real error must surface immediately, no retry + + def test_reraises_after_exhausting_attempts(self, _clean_local, monkeypatch): + err = psycopg2.OperationalError("still down") + # Every connection (initial + reconnect) is dead → both attempts fail. + monkeypatch.setattr( + pg_barrier, "create_pg_connection", lambda **_k: _FakeConn(execute_error=err) + ) + + with pytest.raises(psycopg2.OperationalError, match="still down"): + pg_barrier._run_idempotent_pre_dispatch_write( + lambda cur: cur.execute("X"), what="test" + ) + + # --- Layer 2: enqueue + link/abort with a real injected connection --- @@ -182,6 +317,38 @@ def test_upsert_creates_row_and_attaches_links(self, barrier_db): cloned.link_error.assert_called_once() cloned.apply_async.assert_called_once() + def test_enqueue_self_heals_on_stale_connection(self, barrier_db, monkeypatch): + # End-to-end through enqueue(): the first barrier write hits a dead cached + # conn; the fix must reconnect to the REAL db, land the row exactly once, + # and still dispatch every header exactly once (no double-dispatch). This + # pins the wiring — reverting enqueue() to the inline `with _cursor()` + # would make this fail (the no-DB retry tests alone would not catch that). + monkeypatch.setattr(pg_barrier.time, "sleep", lambda *_a, **_k: None) + dead = _FakeConn( + execute_error=psycopg2.OperationalError( + "server closed the connection unexpectedly" + ) + ) + pg_barrier._local.conn = ( + dead # the barrier_db fixture's real conn is the reconnect target + ) + monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: barrier_db) + + tasks = [_mock_header_task() for _ in range(3)] + handle = PgBarrier().enqueue( + [t for t, _ in tasks], + callback_task_name="cb", + callback_kwargs={"execution_id": "exec-HEAL"}, + callback_queue="general", + app_instance=None, + ) + + assert handle.id == "exec-HEAL" + assert dead.closed is True # stale conn discarded by _cursor + assert _row(barrier_db, "exec-HEAL") == (3, []) # row landed once, remaining=N + for _, cloned in tasks: # every header dispatched exactly once + cloned.apply_async.assert_called_once() + def test_fairness_header_stamped(self, barrier_db): task, cloned = _mock_header_task() PgBarrier().enqueue( @@ -484,7 +651,6 @@ def test_concurrent_aborts_deduplicate(self, barrier_db): def test_registered_under_canonical_name(self): assert barrier_pg_abort.name == "barrier_pg_abort" - def test_max_retries_zero(self): # A Celery retry would replay the decrement and corrupt the count. assert barrier_pg_decr_and_check.max_retries == 0 @@ -795,9 +961,7 @@ def dispatch_side_effect(*args, **kwargs): raise RuntimeError("broker down") dispatch_side_effect.claimed = False - with patch( - "queue_backend.dispatch.dispatch", side_effect=dispatch_side_effect - ): + with patch("queue_backend.dispatch.dispatch", side_effect=dispatch_side_effect): with pytest.raises(RuntimeError, match="broker down"): PgBarrier().enqueue( [_pg_header(), _pg_header()], From 5d07155678e37e4de9cf314ba4ea061b12206dc6 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:49:57 +0530 Subject: [PATCH 44/65] =?UTF-8?q?UN-3652=20[FIX]=20PG=20orchestration=20fa?= =?UTF-8?q?ilure=20=E2=80=94=20reconcile=20file=20counters=20+=20surface?= =?UTF-8?q?=20error=20to=20UI=20(#2122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3652 [FIX] PG orchestration failure — reconcile file counters + surface error to UI When a PG-routed orchestration fails (e.g. the barrier-enqueue blip, or any failure after total_files is set), two UX gaps remained: the execution went ERROR but its files showed as perpetually "in progress" (UI = total - successful - failed, with successful/failed left NULL), and the failure reason never reached the UI (only workflow_execution.error_message + worker logs). Fix, gated behind is_pg_transport (Celery branch byte-identical): - New pure helper WorkflowOrchestrationUtils.pg_failure_file_counts(transport, total_files): PG -> {total_files, successful_files: 0, failed_files: total} so a failed run reads "N failed" (in-progress = 0); non-PG -> {} (the gate). total_files is included because the backend update_status serializer rejects file aggregates without it ("total_files is required when file aggregates are provided"; also successful + failed <= total). - general/tasks.py _execute_general_workflow + api-deployment/tasks.py _run_workflow_api failure handlers: on PG, pass the counts to update_workflow_execution_status (B) and emit the error to the UI/WS via the workflow logger (C). api resolves transport defensively (assigned inside try). Tests: +14 helper/gating unit tests (incl. serializer-constraint guards); related suites green; ruff clean. Dev-tested live E2E on a rebuilt image (flag on, real benchmark API deployment, barrier table renamed to force a non-retryable orchestration failure): - B: failed PG run -> total=1, successful=0, failed=1 (UI in-progress = 0). - C: "❌ Workflow orchestration failed: ..." published to the execution's WS log. The E2E caught a real bug a unit test missed: the first cut omitted total_files and the backend rejected the update with 400 — fixed and re-verified. Co-Authored-By: Claude Opus 4.8 * UN-3652 [FIX] address PR #2122 review — guard status update, hoist transport, test handler Review (greptile P1 + PR-review-toolkit). Changes: - Extract the PG failure recording into one tested seam, WorkflowOrchestrationUtils.record_pg_orchestration_failure (replaces pg_failure_file_counts). It (C) surfaces the error to the UI logger (guarded) and (B) reconciles counters via update_status, with the update WRAPPED in try/except so a status-update failure (e.g. a 400 serializer error) is logged but never re-raised — it can no longer mask the original orchestration error or skip the caller's re-raise (greptile P1). Never raises. - api-deployment: resolve `transport` ONCE up front from kwargs (above the try) and reference it directly, instead of `locals().get("transport", ...)` which fell back to celery for a genuine PG run that failed BEFORE the in-try assignment — re-introducing the exact "stuck in progress" symptom. Dropped the duplicate normalize_transport. - Both handlers now branch: PG -> record_pg_orchestration_failure; else -> the original bare status update, so the Celery path stays byte-identical. - general: the UI-logger call is now None-guarded inside the helper (was an unguarded workflow_logger.log_error). - Tests: replace the helper-only dict tests with behaviour tests of the recorder (mocked api_client/logger): counter reconciliation + total_files, error surfacing, logger-hiccup-doesn't-block-update, update-failure-swallowed-not- raised, and a kwarg-seam guard binding the counts against InternalAPIClient.update_workflow_execution_status's signature. Re-verified live E2E on the refactored handler (renamed pg_barrier_state to force a non-retryable failure): PG run -> ERROR total=1/ok=0/fail=1 (in-progress 0) + "❌ Workflow orchestration failed" published to the WS log channel. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/api-deployment/tasks.py | 56 ++++++++--- workers/general/tasks.py | 25 ++++- .../workflow/execution/orchestration_utils.py | 61 +++++++++++- .../tests/test_pg_orchestration_failure.py | 97 +++++++++++++++++++ 4 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 workers/tests/test_pg_orchestration_failure.py diff --git a/workers/api-deployment/tasks.py b/workers/api-deployment/tasks.py index 1aafe70bad..a07ef7b7bd 100644 --- a/workers/api-deployment/tasks.py +++ b/workers/api-deployment/tasks.py @@ -31,6 +31,7 @@ ExecutionStatus, FileHashData, WorkerFileData, + is_pg_transport, normalize_transport, ) from unstract.core.worker_models import ApiDeploymentResultStatus @@ -421,6 +422,15 @@ def _run_workflow_api( WorkflowHelper.process_input_files() methods. """ total_files = len(hash_values_of_files) + # Resolve transport ONCE, up front from the authoritative kwargs source, so + # the failure handler can rely on it even if orchestration fails before the + # fan-out (the PR-1 seam; the fan-out honours it → PG path = PgBarrier). + # Fail-closed to celery on any unrecognised value. + transport = normalize_transport( + kwargs.get("transport", DEFAULT_WORKFLOW_TRANSPORT), + logger=logger, + context=f" [exec:{execution_id}]", + ) # TOOL VALIDATION: Validate tool instances before API workflow orchestration # Get workflow execution context to retrieve tool instances @@ -689,14 +699,7 @@ def _run_workflow_api( org_id=str(schema_name), workload_type=WorkloadType.API, ) - # Transport rides in via the dispatched task's kwargs (PR 1 seam); the - # fan-out honours it (PG path → fire-and-forget PgBarrier). Fail-closed - # to celery on any unrecognised value. - transport = normalize_transport( - kwargs.get("transport", DEFAULT_WORKFLOW_TRANSPORT), - logger=logger, - context=f" [exec:{execution_id}]", - ) + # transport was resolved up front (see top of _run_workflow_api). result = WorkflowOrchestrationUtils.create_chord_execution( batch_tasks=batch_tasks, callback_task_name="process_batch_callback_api", @@ -806,12 +809,37 @@ def _run_workflow_api( } except Exception as e: - # Update execution to ERROR status matching Django pattern - api_client.update_workflow_execution_status( - execution_id=execution_id, - status=ExecutionStatus.ERROR.value, - error_message=f"Error while processing files: {str(e)}", - ) + error_message = f"Error while processing files: {str(e)}" + # On PG, surface the error to the UI + reconcile file counters so a failed + # run reads "N failed" not "N in progress"; the wrapped update inside the + # helper guarantees a status-update failure can't mask `e` or skip the + # re-raise below (see record_pg_orchestration_failure). The Celery branch + # is the original bare update, untouched. + if is_pg_transport(transport): + error_logger = None + try: + error_logger = WorkerWorkflowLogger.create_for_api_workflow( + execution_id=str(execution_id), + organization_id=str(schema_name), + pipeline_id=str(pipeline_id) if pipeline_id else None, + ) + except Exception as log_error: + logger.warning(f"Failed to build UI logger: {log_error}") + WorkflowOrchestrationUtils.record_pg_orchestration_failure( + api_client=api_client, + execution_id=execution_id, + total_files=total_files, + error_message=error_message, + logger=logger, + workflow_logger=error_logger, + ) + else: + # Update execution to ERROR status matching Django pattern + api_client.update_workflow_execution_status( + execution_id=execution_id, + status=ExecutionStatus.ERROR.value, + error_message=error_message, + ) logger.error(f"Execution {execution_id} failed: {str(e)}", exc_info=True) raise diff --git a/workers/general/tasks.py b/workers/general/tasks.py index 82669be43a..877f867056 100644 --- a/workers/general/tasks.py +++ b/workers/general/tasks.py @@ -57,6 +57,7 @@ FileBatchData, FileHashData, WorkerFileData, + is_pg_transport, normalize_transport, ) @@ -753,14 +754,28 @@ def _execute_general_workflow( except Exception as e: logger.error(f"Workflow execution failed: {e}") - try: - api_client.update_workflow_execution_status( + # On PG, surface the error to the UI + reconcile file counters so a + # failed run reads "N failed" not "N in progress" (see + # WorkflowOrchestrationUtils.record_pg_orchestration_failure). The + # Celery branch below is the original status update, untouched. + if is_pg_transport(transport): + WorkflowOrchestrationUtils.record_pg_orchestration_failure( + api_client=api_client, execution_id=execution_id, - status=ExecutionStatus.ERROR.value, + total_files=total_files, error_message=str(e), + logger=logger, + workflow_logger=workflow_logger, ) - except Exception as status_error: - logger.warning(f"Failed to update error status: {status_error}") + else: + try: + api_client.update_workflow_execution_status( + execution_id=execution_id, + status=ExecutionStatus.ERROR.value, + error_message=str(e), + ) + except Exception as status_error: + logger.warning(f"Failed to update error status: {status_error}") orchestration_result = WorkerTaskResponse.error_response( execution_id=execution_id, diff --git a/workers/shared/workflow/execution/orchestration_utils.py b/workers/shared/workflow/execution/orchestration_utils.py index 560af7b91d..80bfa0bc23 100644 --- a/workers/shared/workflow/execution/orchestration_utils.py +++ b/workers/shared/workflow/execution/orchestration_utils.py @@ -12,7 +12,11 @@ from queue_backend import BarrierHandle, FairnessKey, get_barrier from queue_backend.pg_barrier import PgBarrier -from unstract.core.data_models import DEFAULT_WORKFLOW_TRANSPORT, is_pg_transport +from unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, + ExecutionStatus, + is_pg_transport, +) from ...enums import FileDestinationType, PipelineType from ...enums.worker_enums import QueueName @@ -48,6 +52,61 @@ def _barrier_for_transport(transport: str) -> Barrier: class WorkflowOrchestrationUtils: """Centralized workflow orchestration patterns and utilities.""" + @staticmethod + def record_pg_orchestration_failure( + *, + api_client: Any, + execution_id: str, + total_files: int, + error_message: str, + logger: Any, + workflow_logger: Any | None = None, + ) -> None: + """Record a **PG** orchestration failure: surface the error to the UI and + reconcile the file counters, both best-effort. + + Call this ONLY on the PG transport (the caller gates on + ``is_pg_transport``); the Celery failure path keeps its own original + status update untouched, so it stays byte-identical. + + Two things happen, neither of which may disturb the caller's control flow + (it still has the original orchestration exception to log / re-raise): + + * **(C)** if a ``workflow_logger`` is given, publish the error to the UI / + WebSocket logs (guarded — a logging hiccup must not abort the rest). + * **(B)** mark the attempted files failed via ``update_status`` so the run + reads "N failed" not "N in progress" (UI derives in-progress as + ``total - successful - failed``). ``total_files`` is sent alongside the + aggregates because the backend serializer requires it ("total_files is + required when file aggregates are provided") and enforces + ``successful + failed <= total`` — both hold here (0 + N <= N). The call + is wrapped: a status-update failure (e.g. a serializer/validation error) + is logged but **not** re-raised, so it can never mask the real + orchestration error or skip the caller's re-raise (greptile P1). + """ + if workflow_logger: + try: + workflow_logger.log_error( + logger, f"❌ Workflow orchestration failed: {error_message}" + ) + except Exception as log_error: + logger.warning(f"Failed to publish error to UI logs: {log_error}") + try: + api_client.update_workflow_execution_status( + execution_id=execution_id, + status=ExecutionStatus.ERROR.value, + error_message=error_message, + total_files=total_files, + successful_files=0, + failed_files=total_files, + ) + except Exception as status_error: + logger.error( + f"Failed to write ERROR status + file counts for execution " + f"{execution_id}: {status_error}", + exc_info=True, + ) + @staticmethod def create_chord_execution( batch_tasks: list[Signature], diff --git a/workers/tests/test_pg_orchestration_failure.py b/workers/tests/test_pg_orchestration_failure.py new file mode 100644 index 0000000000..75adc03a18 --- /dev/null +++ b/workers/tests/test_pg_orchestration_failure.py @@ -0,0 +1,97 @@ +"""Tests for ``WorkflowOrchestrationUtils.record_pg_orchestration_failure`` (UN-3652). + +This is the shared PG orchestration-failure recorder used by both the general and +api workers. The tests pin the actual behaviour the PR adds — error surfacing, +counter reconciliation, and the guarantee that **neither a UI-logging hiccup nor +a status-update failure may disturb the caller's original exception/flow** (it +must never raise, so the handler's own re-raise / error-response always runs). + +The Celery path is byte-identical by construction: this recorder is only invoked +on the ``is_pg_transport`` branch, so a Celery failure never reaches it (and so +never sends file aggregates). +""" + +from __future__ import annotations + +import inspect +from unittest.mock import MagicMock + +from shared.workflow.execution.orchestration_utils import WorkflowOrchestrationUtils + +from unstract.core.data_models import ExecutionStatus + + +def _record(**overrides): + api_client = overrides.pop("api_client", MagicMock()) + logger = overrides.pop("logger", MagicMock()) + workflow_logger = overrides.pop("workflow_logger", MagicMock()) + kwargs = { + "api_client": api_client, + "execution_id": "exec-1", + "total_files": 2, + "error_message": "boom", + "logger": logger, + "workflow_logger": workflow_logger, + } + kwargs.update(overrides) + WorkflowOrchestrationUtils.record_pg_orchestration_failure(**kwargs) + return api_client, logger, workflow_logger + + +class TestRecordPgOrchestrationFailure: + def test_reconciles_counters_via_update_status(self): + # B: marks the attempted files failed (in-progress = total - 0 - total = 0) + # AND includes total_files (the serializer requires it with aggregates). + api_client, _, _ = _record(total_files=2) + api_client.update_workflow_execution_status.assert_called_once_with( + execution_id="exec-1", + status=ExecutionStatus.ERROR.value, + error_message="boom", + total_files=2, + successful_files=0, + failed_files=2, + ) + + def test_surfaces_error_to_ui_logger(self): + # C: the failure reason is published to the UI/WS logger. + _, _, wl = _record() + assert wl.log_error.called + assert "boom" in wl.log_error.call_args.args[1] + + def test_no_logger_still_reconciles(self): + api_client, _, _ = _record(workflow_logger=None) + api_client.update_workflow_execution_status.assert_called_once() + + def test_ui_logger_failure_does_not_block_update(self): + # A WS hiccup must not abort the counter reconciliation. + wl = MagicMock() + wl.log_error.side_effect = RuntimeError("ws down") + api_client, logger, _ = _record(workflow_logger=wl) + api_client.update_workflow_execution_status.assert_called_once() + assert logger.warning.called + + def test_status_update_failure_is_swallowed_not_raised(self): + # The greptile P1 guarantee: a rejected update (e.g. a 400 serializer + # error) is logged but NOT re-raised, so it can't mask the caller's + # original orchestration exception or skip its re-raise. + api_client = MagicMock() + api_client.update_workflow_execution_status.side_effect = ValueError("400") + # Must not raise: + _, logger, _ = _record(api_client=api_client) + assert logger.error.called # logged at error level (a real defect signal) + + def test_count_keys_are_valid_update_status_kwargs(self): + # Guard the ``**counts`` seam against a kwarg rename on either side — a + # mismatch would otherwise only surface as a runtime TypeError on the rare + # PG-failure path. + from shared.api.internal_client import InternalAPIClient + + sig = inspect.signature(InternalAPIClient.update_workflow_execution_status) + sig.bind_partial( + execution_id="x", + status=ExecutionStatus.ERROR.value, + error_message="e", + total_files=2, + successful_files=0, + failed_files=2, + ) From 89b4b116e95a9b4a94a05883d3813630ed19b511 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:09:10 +0530 Subject: [PATCH 45/65] =?UTF-8?q?UN-3654=20[FIX]=20PG=20queue=20=E2=80=94?= =?UTF-8?q?=20reconnect-retry=20on=20stale=20dispatch=20connection=20(send?= =?UTF-8?q?())=20(#2124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3654 [FIX] PG queue — reconnect-retry on stale dispatch connection (send()) A PG-transport execution aborted with "server closed the connection unexpectedly" at barrier header dispatch ~30 min after the prior run on the same worker thread: PgBouncer server_idle_timeout reaped the cached dispatch connection while idle. UN-3651's reconnect-retry covers only the barrier's idempotent pre-dispatch write; the next call — PgQueueClient.send() INSERT into pg_queue_message — used a separate cached connection with no reconnect logic, so it failed and aborted the execution. send()'s INSERT is not idempotent (auto msg_id), so a blind retry could double-enqueue. The one-shot reconnect-retry therefore fires ONLY when the failing connection was reused (cached + owned): a reused conn that dies on its first statement was reaped while idle, so the INSERT never reached the server and re-inserting can't duplicate. A fresh connection failing re-raises without retry. PG transport only; Celery dispatch untouched. Tests: 5 new (reused-stale→retry; fresh→no retry; injected→no retry; retry-also-fails→raise once; non-connection error→no retry). All green. Co-Authored-By: Claude Opus 4.8 * UN-3654 [FIX] address PR #2124 review — honest at-least-once framing, shared error tuple, keyword-only, test coverage - reframe send() docstring: reused gate makes idle-reap retry safe but enqueue is at-least-once (commit-loss case); lean on module idempotent-consumer contract; claim_batch/pg_batch_dedup is the batch-header-specific instance only - extract _CONN_DEAD_ERRORS shared by _cursor + send(); note bare DatabaseError left to next-call reconnect - retie backoff-constant rationale to pause duration (+ async caveat); WARNING describes observation not conclusion; add success breadcrumb on reconnect - _insert_message org_id/priority keyword-only - tests: parametrize Operational/InterfaceError, assert sleep backoff + retry param passthrough + caplog WARNING Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/queue_backend/pg_queue/client.py | 112 ++++++++++++++++++++- workers/tests/test_pg_queue_client.py | 121 +++++++++++++++++++++++ 2 files changed, 229 insertions(+), 4 deletions(-) diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py index 05832a1487..c53b95953e 100644 --- a/workers/queue_backend/pg_queue/client.py +++ b/workers/queue_backend/pg_queue/client.py @@ -29,9 +29,10 @@ import contextlib import json import logging +import time from collections.abc import Iterator from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING, Any, Final, Self import psycopg2 @@ -45,6 +46,18 @@ logger = logging.getLogger(__name__) +# psycopg2 errors that mean the connection itself is dead (dropped socket / +# PgBouncer recycle / server termination) rather than a statement-level fault. +# Shared by ``_cursor`` (decides whether to discard the cached handle) and +# ``send`` (decides whether the one-shot reconnect-retry is eligible) so the two +# sites can't drift — narrowing one without the other would silently break the +# retry's "was this a connection death?" test. +_CONN_DEAD_ERRORS: Final[tuple[type[Exception], ...]] = ( + psycopg2.OperationalError, + psycopg2.InterfaceError, +) + + # Atomic claim. Takes up to %(qty)s ready messages no other transaction # holds, makes them invisible for %(vt)s seconds, returns them. SKIP LOCKED # => concurrent readers never claim the same visible row (no concurrent @@ -147,6 +160,19 @@ def insert_message_sql() -> str: ) +# Pause duration before send()'s single reconnect-retry (see send()). This is +# the length of the pause, NOT the retry count — the one-shot bound is enforced +# structurally by send()'s single ``except`` + single retry call, not by this +# value. A literal rather than env-driven so the pause can't be tuned into a +# long blocking stall on the enqueue hot path. Note the sleep penalises the +# common idle-reap path (which reconnects instantly) purely to buy a self-heal +# window for the rarer brief DB failover, and to avoid re-hammering a struggling +# server. Caveat: this is a blocking ``time.sleep``; if send() is ever pulled +# into an async context it becomes an event-loop stall and must move to +# ``asyncio.sleep``. +_SEND_RETRY_BACKOFF_SECONDS: Final = 0.5 + + class PgQueueClient: """``send`` / ``read`` / ``delete`` over ``pg_queue_message``. @@ -185,9 +211,13 @@ def _cursor(self) -> Iterator[Any]: # A failed rollback proves the connection is unusable, regardless # of which psycopg2 error subclass was raised (a server-side # termination mid-statement can surface as a bare DatabaseError). - conn_dead = isinstance( - exc, (psycopg2.OperationalError, psycopg2.InterfaceError) - ) + # NOTE: ``send()``'s retry deliberately catches only + # ``_CONN_DEAD_ERRORS``; a server death that surfaces as a *bare* + # ``psycopg2.DatabaseError`` is still treated as dead here (via the + # failed-rollback branch below, which drops the handle) but is + # intentionally NOT retried by ``send()`` — it's left to the next + # call's reconnect. + conn_dead = isinstance(exc, _CONN_DEAD_ERRORS) try: conn.rollback() except Exception: @@ -220,11 +250,85 @@ def send( MAX_PRIORITY]`` — out of range raises (it would silently jump/sink the row in the ``priority DESC`` claim order), mirroring ``read()``'s guards. The DB ``CheckConstraint`` is the backstop for any ORM/raw writer. + + Reconnect-retry: the cached connection can be reaped server-side + (PgBouncer ``server_idle_timeout`` / DB failover) while idle BETWEEN + sends, and ``conn.closed`` is a client-side flag only — so the first + ``execute`` after the idle gap fails (this aborted whole executions at + the barrier's header dispatch). We retry ONCE, but only on + ``_CONN_DEAD_ERRORS`` and only when the failing connection was + **reused**. The ``reused`` gate only distinguishes "cached BEFORE this + call" from "created DURING it" (captured before the attempt, which is + correct) — it makes the retry safe for the **idle-reap** case: a conn + reaped while idle dies on its first statement, so the ``INSERT`` never + ran and re-inserting can't duplicate. A **fresh** connection failing is + a genuine error → re-raise, never retry. + + This is NOT exactly-once. A connection that dies AFTER the server has + committed the row but BEFORE psycopg2 read back ``RETURNING msg_id`` + (the commit-loss / PgBouncer server-recycle case this very feature + targets) is indistinguishable here from an idle reap, so the retry would + re-insert an already-committed row. The enqueue is therefore + **at-least-once**: the ``reused`` gate removes the *common* duplicate + (idle reap) but cannot remove the *rare* one (post-commit death). That + residual duplicate leans on the module's at-least-once / idempotent- + consumer contract (see the module docstring header) as the GENERAL + backstop — every consumer here must already tolerate redelivery. + ``claim_batch`` / ``pg_batch_dedup`` is only the **batch-header-specific** + instance of that idempotency: it dedups duplicate batch *headers*, but + does NOT gate leaf tasks or the aggregating callback (which also reach + this ``send()`` via dispatch.py) — so it does not absorb every duplicate, + only batch-header ones. """ if not MIN_PRIORITY <= priority <= MAX_PRIORITY: raise ValueError( f"priority out of range [{MIN_PRIORITY}, {MAX_PRIORITY}]: {priority!r}" ) + # Capture BEFORE the attempt: a fresh conn has self._conn is None here. + reused = self._conn is not None and self._owns_conn + try: + return self._insert_message( + queue_name, message, org_id=org_id, priority=priority + ) + except _CONN_DEAD_ERRORS as exc: + if not reused: + raise + # Describe what we observed, not a verdict: a connection-level error + # on a reused conn is usually a stale idle reap, but a real DB + # outage looks the same here — don't assert "stale" as fact. + logger.warning( + "PG-queue: send to queue=%r failed with a connection-level error " + "on a reused cached connection (%s: %s); dropping it and retrying " + "once (stale reap or DB unavailable)", + queue_name, + type(exc).__name__, + exc, + exc_info=True, + ) + time.sleep(_SEND_RETRY_BACKOFF_SECONDS) + # _cursor already dropped the dead owned conn, so this reconnects. + msg_id = self._insert_message( + queue_name, message, org_id=org_id, priority=priority + ) + # Positive breadcrumb: the primary hazard is a silent duplicate + # enqueue (at-least-once, see docstring) — record the reconnect with + # the returned msg_id so a duplicate is correlatable after the fact. + logger.info( + "PG-queue: send to queue=%r succeeded on reconnect (msg_id=%s)", + queue_name, + msg_id, + ) + return msg_id + + def _insert_message( + self, + queue_name: str, + message: dict[str, Any], + *, + org_id: str | None, + priority: int, + ) -> int: + """One INSERT of a queue row, returning its ``msg_id`` (see :meth:`send`).""" with self._cursor() as cur: cur.execute( insert_message_sql() + " RETURNING msg_id", diff --git a/workers/tests/test_pg_queue_client.py b/workers/tests/test_pg_queue_client.py index ad348ad58d..939d319e5a 100644 --- a/workers/tests/test_pg_queue_client.py +++ b/workers/tests/test_pg_queue_client.py @@ -13,6 +13,7 @@ from __future__ import annotations +import logging import os import time import uuid @@ -22,6 +23,7 @@ import pytest from queue_backend.fairness import DEFAULT_PRIORITY, MAX_PRIORITY, MIN_PRIORITY from queue_backend.pg_queue import PgQueueClient, QueueMessage +from queue_backend.pg_queue.client import _SEND_RETRY_BACKOFF_SECONDS from queue_backend.pg_queue.connection import create_pg_connection from queue_backend.pg_queue.schema import qualified @@ -197,6 +199,125 @@ def test_close_closes_owned_not_injected(self, monkeypatch): injected.close.assert_not_called() +class TestSendReconnectRetry: + """``send()``'s one-shot reconnect-retry for a reused, idle-reaped connection. + + A cached connection can be reaped server-side (PgBouncer + ``server_idle_timeout`` / failover) while idle between sends; the first + statement after the gap then fails. ``send()`` retries ONCE — but only when + the failing connection was **reused** (cached + owned), so the ``INSERT`` + (which is not idempotent) can't be double-enqueued: a reused conn that dies + on its first statement was reaped while idle and never ran the write. + """ + + @staticmethod + def _conn(*, execute_side_effect=None, fetchone=(1,)): + cur = MagicMock() + if execute_side_effect is not None: + cur.execute.side_effect = execute_side_effect + cur.fetchone.return_value = fetchone + conn = MagicMock() + conn.closed = 0 + conn.cursor.return_value = _CursorCtx(cur) + return conn, cur + + @staticmethod + def _no_sleep(monkeypatch): + sleep = MagicMock() + monkeypatch.setattr("queue_backend.pg_queue.client.time.sleep", sleep) + return sleep + + @pytest.mark.parametrize( + "exc_type", [psycopg2.OperationalError, psycopg2.InterfaceError] + ) + def test_reused_stale_conn_retries_and_succeeds(self, monkeypatch, caplog, exc_type): + # Cached owned conn reaped while idle fails its first statement; the + # one-shot retry reconnects (factory) and the INSERT lands. Exercise + # BOTH connection-dead error types — InterfaceError is the more likely + # stale symptom and is otherwise never covered, so narrowing the except + # to OperationalError alone would silently pass. + dead, _ = self._conn(execute_side_effect=exc_type("idle reap")) + fresh, fresh_cur = self._conn(fetchone=(77,)) + factory = MagicMock(return_value=fresh) + monkeypatch.setattr("queue_backend.pg_queue.client.create_pg_connection", factory) + sleep = self._no_sleep(monkeypatch) + client = PgQueueClient() # owns its connection + client._conn = dead # simulate a cached (reused) connection + + with caplog.at_level(logging.WARNING, logger="queue_backend.pg_queue.client"): + msg_id = client.send("q", {"a": 1}, org_id="org-7", priority=9) + assert msg_id == 77 # the retry's INSERT + dead.close.assert_called_once() # stale conn discarded + factory.assert_called_once() # reconnected exactly once + # The backoff actually fired (don't just discard the _no_sleep mock). + sleep.assert_called_once_with(_SEND_RETRY_BACKOFF_SECONDS) + # The retry's INSERT carries the passthrough params — a params-drop on + # the reconnect path would be caught here (assert on the FRESH cursor). + _, params = fresh_cur.execute.call_args.args + assert params[0] == "q" # queue_name + assert params[2] == "org-7" # org_id + assert params[3] == 9 # priority + # The connection-level failure surfaced as a WARNING on the retry. + assert any(r.levelno == logging.WARNING for r in caplog.records) + assert "retrying once" in caplog.text + + def test_fresh_conn_failure_does_not_retry(self, monkeypatch): + # First-ever send (self._conn is None) is on a FRESH conn — a failure + # there is a genuine error / ambiguous; must NOT retry (could duplicate). + dead, _ = self._conn(execute_side_effect=psycopg2.OperationalError("down")) + factory = MagicMock(return_value=dead) + monkeypatch.setattr("queue_backend.pg_queue.client.create_pg_connection", factory) + sleep = self._no_sleep(monkeypatch) + client = PgQueueClient() + + with pytest.raises(psycopg2.OperationalError): + client.send("q", {"a": 1}) + factory.assert_called_once() # created once, NOT retried + sleep.assert_not_called() + + def test_injected_conn_not_retried(self, monkeypatch): + # An injected (caller-owned) conn is never recycled, so retrying can't + # reconnect — and isn't ours to retry. Re-raise without retry. + conn, _ = self._conn(execute_side_effect=psycopg2.OperationalError("dead")) + sleep = self._no_sleep(monkeypatch) + client = PgQueueClient(conn=conn) + + with pytest.raises(psycopg2.OperationalError): + client.send("q", {"a": 1}) + sleep.assert_not_called() + conn.close.assert_not_called() # caller's connection untouched + + def test_retry_failure_reraises_once(self, monkeypatch): + # If the reconnected attempt also fails, raise — exactly one reconnect, + # no loop. + dead1, _ = self._conn(execute_side_effect=psycopg2.OperationalError("reap")) + dead2, _ = self._conn(execute_side_effect=psycopg2.OperationalError("still")) + factory = MagicMock(return_value=dead2) + monkeypatch.setattr("queue_backend.pg_queue.client.create_pg_connection", factory) + self._no_sleep(monkeypatch) + client = PgQueueClient() + client._conn = dead1 + + with pytest.raises(psycopg2.OperationalError): + client.send("q", {"a": 1}) + factory.assert_called_once() # one reconnect only + + def test_reused_conn_non_connection_error_not_retried(self, monkeypatch): + # A logical error (not Operational/Interface) on a reused conn is not a + # stale-connection symptom → re-raise, no reconnect. + bad, _ = self._conn(execute_side_effect=RuntimeError("logic")) + factory = MagicMock() + monkeypatch.setattr("queue_backend.pg_queue.client.create_pg_connection", factory) + sleep = self._no_sleep(monkeypatch) + client = PgQueueClient() + client._conn = bad + + with pytest.raises(RuntimeError): + client.send("q", {"a": 1}) + factory.assert_not_called() + sleep.assert_not_called() + + class TestCreatePgConnection: """Unit coverage for the connection factory (no real DB).""" From cd965d7739e1b6b2f2d10815d1edd720d8827c3d Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:18:27 +0530 Subject: [PATCH 46/65] =?UTF-8?q?UN-3655=20[FIX]=20HITL=20queue=20?= =?UTF-8?q?=E2=80=94=20populate=20execution=5Fid=20on=20enqueue=20(QueueRe?= =?UTF-8?q?sult)=20(#2125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3655 [FIX] HITL queue — populate execution_id on enqueue (QueueResult) hitl_queue.execution_id was NULL on 100% of rows (2301/2301 in production since 2026-02-03, both Celery and PG transports) — a HITL item couldn't be traced to its workflow_execution without joining through workflow_file_execution. The ETL manual-review push serialises a QueueResult into the queue message, but QueueResult carried workflow_id + file_execution_id and NO execution_id, so _push_data_to_queue omitted it even though self.execution_id is in scope. The backend manual-review enqueue ALREADY reads message["execution_id"], so it persisted NULL only because the message lacked the key. Adds execution_id to the QueueResult dataclass + to_dict(), and passes self.execution_id at the destination push site. The API/packet path already includes execution_id in its message. OSS-only; no cloud/backend change. Transport-agnostic (also fixes the existing Celery path). Additive — the field defaults to None, so callers that don't set it are unaffected. Tests: QueueResult.to_dict() includes execution_id (set + default-None). Co-Authored-By: Claude Opus 4.8 * UN-3655 [FIX] address PR #2125 review — wiring test, missing-value warning, comment scope, backend QueueResult parity - add the highest-value missing test: exercise _push_data_to_queue and assert the dict at the enqueue boundary carries the connector's execution_id, distinct from file_execution_id (catches a dropped kwarg / adjacent-field swap on the integration line — the to_dict() tests can't see it) - QueueResult.__post_init__ logs a WARNING when execution_id is missing instead of silently writing a NULL (kept optional, not a hard raise: the connector's execution_id is nullable on some paths, so a raise would break HITL enqueue) - rescope the field comment + test docstring to what this repo controls (the producer half); name the out-of-tree consumer (manual_review_v2), drop the backend-accessor assertion - mirror the additive fix into the backend QueueResult (endpoint_v2/queue_utils.py + destination._create_queue_result) so the column is populated whichever destination path enqueues — addresses the out-of-diff note; backend parity test added Deferred (noted on PR): to_dict() -> dataclasses.asdict() refactor (out of scope; changing the serialization of every field carries its own regression surface). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../endpoint_v2/destination.py | 1 + .../endpoint_v2/queue_utils.py | 6 + .../tests/test_queue_result_execution_id.py | 50 ++++++++ workers/shared/models/result_models.py | 23 ++++ .../shared/workflow/destination_connector.py | 1 + .../tests/test_queue_result_execution_id.py | 119 ++++++++++++++++++ 6 files changed, 200 insertions(+) create mode 100644 backend/workflow_manager/endpoint_v2/tests/test_queue_result_execution_id.py create mode 100644 workers/tests/test_queue_result_execution_id.py diff --git a/backend/workflow_manager/endpoint_v2/destination.py b/backend/workflow_manager/endpoint_v2/destination.py index 093c64b905..fbfb17c518 100644 --- a/backend/workflow_manager/endpoint_v2/destination.py +++ b/backend/workflow_manager/endpoint_v2/destination.py @@ -1017,6 +1017,7 @@ def _create_queue_result( file_content=file_content_base64, whisper_hash=whisper_hash, file_execution_id=file_execution_id, + execution_id=self.execution_id, extracted_text=extracted_text, ttl_seconds=ttl_seconds, hitl_reason=hitl_reason, diff --git a/backend/workflow_manager/endpoint_v2/queue_utils.py b/backend/workflow_manager/endpoint_v2/queue_utils.py index 204518f9ae..5e44998be5 100644 --- a/backend/workflow_manager/endpoint_v2/queue_utils.py +++ b/backend/workflow_manager/endpoint_v2/queue_utils.py @@ -237,6 +237,11 @@ class QueueResult: file_content: str whisper_hash: str | None = None file_execution_id: str | None = None + # Workflow execution id — carried into the HITL queue message so the + # manual-review consumer can populate hitl_queue.execution_id. Mirrors the + # workers QueueResult (shared/models/result_models.py); kept in sync so the + # column is populated whichever destination path enqueues the review. + execution_id: str | None = None enqueued_at: float | None = None ttl_seconds: int | None = None extracted_text: str | None = None @@ -264,6 +269,7 @@ def to_dict(self) -> Any: "workflow_id": self.workflow_id, "file_content": self.file_content, "file_execution_id": self.file_execution_id, + "execution_id": self.execution_id, "enqueued_at": self.enqueued_at, "ttl_seconds": self.ttl_seconds, "extracted_text": self.extracted_text, diff --git a/backend/workflow_manager/endpoint_v2/tests/test_queue_result_execution_id.py b/backend/workflow_manager/endpoint_v2/tests/test_queue_result_execution_id.py new file mode 100644 index 0000000000..b03e3aaf8f --- /dev/null +++ b/backend/workflow_manager/endpoint_v2/tests/test_queue_result_execution_id.py @@ -0,0 +1,50 @@ +"""UN-3655: backend ``QueueResult`` carries ``execution_id`` (parity with the +workers ``QueueResult``). + +The backend ``DestinationConnector`` is a second producer of the HITL queue +message. It must emit the same ``execution_id`` key so ``hitl_queue.execution_id`` +is populated whichever destination path enqueues the review. Pins the producer +half only (the consumer column write lives in ``manual_review_v2``). +""" + +from __future__ import annotations + +import os + +import django +from django.apps import apps + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") +if not apps.ready: + django.setup() + +from workflow_manager.endpoint_v2.queue_utils import ( # noqa: E402 + QueueResult, + QueueResultStatus, +) + + +def test_backend_queue_result_to_dict_includes_execution_id(): + d = QueueResult( + file="doc.pdf", + status=QueueResultStatus.SUCCESS, + result={"k": "v"}, + workflow_id="wf-1", + file_content="b64", + file_execution_id="fexec-1", + execution_id="exec-1", + ).to_dict() + assert d["execution_id"] == "exec-1" + assert d["file_execution_id"] == "fexec-1" + + +def test_backend_execution_id_defaults_to_none_but_key_present(): + d = QueueResult( + file="doc.pdf", + status=QueueResultStatus.SUCCESS, + result={}, + workflow_id="wf-1", + file_content="b64", + ).to_dict() + assert "execution_id" in d + assert d["execution_id"] is None diff --git a/workers/shared/models/result_models.py b/workers/shared/models/result_models.py index 547145b922..29a75cabf0 100644 --- a/workers/shared/models/result_models.py +++ b/workers/shared/models/result_models.py @@ -3,6 +3,7 @@ Dataclasses for task execution results. """ +import logging import os import sys import time @@ -19,6 +20,8 @@ # Import worker enums from ..enums import WebhookStatus +logger = logging.getLogger(__name__) + @dataclass class WebhookResult: @@ -223,6 +226,15 @@ class QueueResult: file_content: str | None = None whisper_hash: str | None = None file_execution_id: str | None = None + # Workflow execution id — carried into the HITL queue message so the + # manual-review consumer (pluggable_apps.manual_review_v2, a separate + # codebase) can populate hitl_queue.execution_id. The durable local fact is + # that to_dict() always emits this key (None when unset); the key name must + # stay in sync with that consumer. Optional with a None default because the + # connector's execution_id is itself nullable on some paths — a missing + # value is logged in __post_init__ rather than raised, so an unexpected NULL + # is visible without breaking the HITL enqueue. + execution_id: str | None = None enqueued_at: float | None = None ttl_seconds: int | None = None extracted_text: str | None = None @@ -241,6 +253,16 @@ def __post_init__(self): raise ValueError("QueueResult requires a valid workflow_id") if self.status is None: raise ValueError("QueueResult requires a valid status") + # execution_id should always be set on the HITL enqueue path; it's not a + # hard requirement (the connector's value is nullable on some paths and a + # raise would break manual-review enqueue), so surface a missing value + # rather than silently writing a NULL hitl_queue.execution_id. + if not self.execution_id: + logger.warning( + "QueueResult for file=%r has no execution_id; " + "hitl_queue.execution_id will be NULL", + self.file, + ) def to_dict(self) -> Any: result_dict = { @@ -251,6 +273,7 @@ def to_dict(self) -> Any: "workflow_id": self.workflow_id, "file_content": self.file_content, "file_execution_id": self.file_execution_id, + "execution_id": self.execution_id, "enqueued_at": self.enqueued_at, "ttl_seconds": self.ttl_seconds, "extracted_text": self.extracted_text, diff --git a/workers/shared/workflow/destination_connector.py b/workers/shared/workflow/destination_connector.py index 8a9fcf8308..7d0d62ebeb 100644 --- a/workers/shared/workflow/destination_connector.py +++ b/workers/shared/workflow/destination_connector.py @@ -1833,6 +1833,7 @@ def _push_data_to_queue( workflow_id=str(self.workflow_id), whisper_hash=whisper_hash, file_execution_id=file_execution_id, + execution_id=self.execution_id, extracted_text=extracted_text, ttl_seconds=ttl_seconds, hitl_reason=hitl_reason, diff --git a/workers/tests/test_queue_result_execution_id.py b/workers/tests/test_queue_result_execution_id.py new file mode 100644 index 0000000000..94854dbd24 --- /dev/null +++ b/workers/tests/test_queue_result_execution_id.py @@ -0,0 +1,119 @@ +"""UN-3655: ``QueueResult`` carries ``execution_id`` into the HITL queue message. + +These tests pin the **producer half** of the contract — that the ETL +manual-review push emits a queue message with a key named exactly +``execution_id`` (carrying the connector's value), so the downstream column +stops being NULL. The consumer half (``pluggable_apps.manual_review_v2`` writing +``hitl_queue.execution_id``) lives out-of-tree and is context, not something +these tests guarantee. +""" + +from __future__ import annotations + +import logging +from unittest.mock import MagicMock, patch + +from shared.enums import QueueResultStatus +from shared.models.result_models import QueueResult + + +def _result(**overrides): + kwargs = { + "file": "doc.pdf", + "status": QueueResultStatus.SUCCESS, + "result": {"k": "v"}, + "workflow_id": "wf-1", + "file_execution_id": "fexec-1", + "execution_id": "exec-1", + } + kwargs.update(overrides) + return QueueResult(**kwargs) + + +def test_to_dict_includes_execution_id(): + # The backend reads message["execution_id"] — the key + value must survive. + d = _result().to_dict() + assert d["execution_id"] == "exec-1" + # And it sits alongside file_execution_id (both correlate the review item). + assert d["file_execution_id"] == "fexec-1" + + +def test_execution_id_defaults_to_none_but_key_present_and_warns(caplog): + # Callers that don't pass it must not break — the key is still emitted + # (None), so the consumer's .get("execution_id") is a clean NULL, not a + # KeyError, and the field is purely additive. But a missing value is a + # latent NULL write, so __post_init__ logs a WARNING rather than failing + # silently (we keep it optional — not a hard raise — because the connector's + # execution_id is nullable on some paths). + with caplog.at_level(logging.WARNING, logger="shared.models.result_models"): + d = QueueResult( + file="doc.pdf", + status=QueueResultStatus.SUCCESS, + result={}, + workflow_id="wf-1", + ).to_dict() + assert "execution_id" in d + assert d["execution_id"] is None + assert any( + r.levelno == logging.WARNING and "execution_id" in r.message + for r in caplog.records + ) + + +def test_no_warning_when_execution_id_present(caplog): + with caplog.at_level(logging.WARNING, logger="shared.models.result_models"): + _result() + assert not any("execution_id" in r.message for r in caplog.records) + + +def test_push_data_to_queue_wires_connector_execution_id(): + """The integration line ``execution_id=self.execution_id`` (the point of the + PR) — assert the dict handed to the enqueue boundary carries the connector's + execution_id, distinct from file_execution_id, so a dropped kwarg or an + adjacent-field swap is caught (the to_dict() tests above can't see that line). + """ + from shared.workflow.destination_connector import WorkerDestinationConnector + + # Bypass the heavy config-driven __init__; set only what _push_data_to_queue + # reads, with execution_id deliberately != file_execution_id. + conn = WorkerDestinationConnector.__new__(WorkerDestinationConnector) + conn.execution_id = "EXEC-distinct" + conn.file_execution_id = "CONNECTOR-FEXEC" + conn.workflow_id = "wf-1" + conn.organization_id = "org-1" + conn.is_api = False + conn.hitl_queue_name = None + conn.hitl_packet_id = None + conn.workflow_log = MagicMock() + conn._ensure_manual_review_service = MagicMock() + conn._ensure_manual_review_service.return_value.get_workflow_util.return_value.get_hitl_ttl_seconds.return_value = 3600 + conn._get_review_queue_name = MagicMock(return_value="review_q") + conn._read_file_from_source_connector = MagicMock(return_value="b64") + conn.get_metadata = MagicMock(return_value={"whisper-hash": "wh"}) + conn._enqueue_to_packet_or_regular_queue = MagicMock() + + with ( + patch( + "shared.workflow.destination_connector.has_manual_review_plugin", + return_value=True, + ), + patch("shared.workflow.destination_connector.log_file_info"), + ): + conn._push_data_to_queue( + file_name="doc.pdf", + workflow={}, + input_file_path="/in/doc.pdf", + file_execution_id="ARG-FEXEC", + tool_execution_result="result-text", + api_client=MagicMock(), + hitl_reason="rule", + ) + + assert conn._enqueue_to_packet_or_regular_queue.called + queue_result = conn._enqueue_to_packet_or_regular_queue.call_args.kwargs[ + "queue_result" + ] + assert queue_result["execution_id"] == "EXEC-distinct" # from self.execution_id + assert queue_result["file_execution_id"] == "ARG-FEXEC" + # Adjacent-field swap (execution_id=self.file_execution_id / the arg) detectable: + assert queue_result["execution_id"] != queue_result["file_execution_id"] From 68d137a826a8265e62ad9abb6f5b71471392eda1 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:20:44 +0530 Subject: [PATCH 47/65] =?UTF-8?q?UN-3657=20[FIX]=20Execution=20serializer?= =?UTF-8?q?=20=E2=80=94=20terminal-failure=20run=20reports=20unaccounted?= =?UTF-8?q?=20files=20as=20failed,=20not=20in-progress=20(#2126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3657 [FIX] Execution serializer — terminal-failure run reports unaccounted files as failed, not in-progress A terminal ERROR/STOPPED execution could show files as still 'in progress' in the UI (observed on exec 4b180f05: ERROR, total_files=2, shown as '2 in progress'). ExecutionSerializer derives successful/failed by COUNTING workflow_file_execution rows; when orchestration fails before those rows are created (0 rows) both read 0, and the UI derives in-progress as total - successful - failed = total -> phantom in-progress for a finished run. get_failed_files now returns total - successful for a terminal FAILURE run (ExecutionStatus.is_failure -> ERROR/STOPPED): every file that didn't succeed in a finished failure run is a failure, including ones that never got a row. Scoped to is_failure, so COMPLETED runs and live (PENDING/EXECUTING) runs keep the exact row-count behaviour -> no-op when rows already account for every file, success path + real-time progress untouched on every transport. max(0, ...) guards a stale successful>total count. OSS read-path only; transport-agnostic (also fixes the latent Celery case). Fix #1 (UN-3654) already PREVENTS the failure that produced this display; this is defense-in-depth for any early-orchestration failure. Tests: serializer unit tests (ORM mocked, no DB) — error/stopped reconcile, completed + executing unchanged, negative-clamp guard. Co-Authored-By: Claude Opus 4.8 * UN-3657 [FIX] address PR #2126 review — no under-report on drift, signal anomalies, DB-free test - P1: get_failed_files no longer trusts total_files blindly. If terminal rows exceed total_files (counter drift / impossible count), fall back to the real ERROR-row count instead of total-successful, so genuine failures can't silently vanish (the inverse of the phantom-in-progress bug). Otherwise unaccounted files (no terminal row) still count as failed. - P2: memoise per-(execution,status) COUNTs so successful/failed don't double-query the same status (also tightens the pre-existing N+1 for these fields). The successful>total clamp now logs a WARNING instead of erasing the anomaly silently. - note that is_failure swallows ValueError -> unknown status takes the row-count path (never raises -> can't 500 the list endpoint). - docstring: fix the 'no-op' claim (only when every file has a terminal row), drop the transport mention + the opaque exec UUID, anchor in-progress to DetailedLogs.jsx. - test harness: force DJANGO_SETTINGS_MODULE (setdefault is a no-op when a dev shell already exports it); stub the model in sys.modules and drop django.setup() so the test is genuinely DB-free (0.26s, no AppConfig.ready / DB connection), mirroring usage_v2/pg_queue sibling tests. - tests added: drift-no-under-report, successful>total clamp+warn, COMPLETED not-reconciled asymmetry, unknown-status-no-raise, and the real total-successful-failed==0 invariant on failure runs. Deferred (noted on PR): an explicit in_progress_files SerializerMethodField so the server owns all three buckets — bigger change (needs the frontend to render it), orthogonal to this fix. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../execution/serializer/execution.py | 56 ++++++- .../execution/tests/__init__.py | 0 .../tests/test_execution_serializer.py | 144 ++++++++++++++++++ 3 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 backend/workflow_manager/execution/tests/__init__.py create mode 100644 backend/workflow_manager/execution/tests/test_execution_serializer.py diff --git a/backend/workflow_manager/execution/serializer/execution.py b/backend/workflow_manager/execution/serializer/execution.py index 0f440cc8ae..23cd7b9540 100644 --- a/backend/workflow_manager/execution/serializer/execution.py +++ b/backend/workflow_manager/execution/serializer/execution.py @@ -1,8 +1,12 @@ +import logging + from rest_framework import serializers from workflow_manager.workflow_v2.enums import ExecutionStatus from workflow_manager.workflow_v2.models import WorkflowExecution +logger = logging.getLogger(__name__) + # TODO: Optimize with select_related / prefetch_related to reduce DB queries class ExecutionSerializer(serializers.ModelSerializer): @@ -25,13 +29,59 @@ def get_pipeline_name(self, obj: WorkflowExecution) -> str | None: """Fetch the pipeline or API deployment name""" return obj.pipeline_name + def _status_count(self, obj: WorkflowExecution, status_value: str) -> int: + """COUNT of this execution's file rows in ``status_value``, memoised per + (execution, status) so the successful/failed fields don't issue + duplicate COUNTs for the same status within one serialization pass. + """ + cache = self.__dict__.setdefault("_status_count_cache", {}) + key = (str(obj.id), status_value) + if key not in cache: + cache[key] = obj.file_executions.filter(status=status_value).count() + return cache[key] + def get_successful_files(self, obj: WorkflowExecution) -> int: """Return the count of successfully executed files""" - return obj.file_executions.filter(status=ExecutionStatus.COMPLETED.value).count() + return self._status_count(obj, ExecutionStatus.COMPLETED.value) def get_failed_files(self, obj: WorkflowExecution) -> int: - """Return the count of failed executed files""" - return obj.file_executions.filter(status=ExecutionStatus.ERROR.value).count() + """Return the count of failed files. + + For a terminal *failure* run (ERROR/STOPPED), files that never reached a + terminal row — orchestration aborted before creating them, or they were + left PENDING/EXECUTING — are failures, not "in progress". The UI derives + in-progress as ``total - successful - failed`` (frontend + ``DetailedLogs.jsx``), so without this a finished failure run shows + phantom in-progress files. A no-op whenever every file already has a + terminal COMPLETED/ERROR row. + + Never under-reports below the real ERROR-row count: if the terminal rows + already exceed ``total_files`` (counter drift / an impossible count) the + derived value would be too low, so fall back to the ERROR rows and log + the drift instead of silently clamping it away. + """ + error_rows = self._status_count(obj, ExecutionStatus.ERROR.value) + # is_failure swallows ValueError -> False for an unrecognised status, so a + # malformed/legacy status safely takes the plain row-count path (no raise, + # so it can't 500 the executions list — but also no reconcile for it). + if not ExecutionStatus.is_failure(obj.status): + return error_rows + successful = self._status_count(obj, ExecutionStatus.COMPLETED.value) + total = obj.total_files or 0 + if successful + error_rows > total: + logger.warning( + "Execution %s terminal file counts exceed total_files " + "(status=%s total=%s successful=%s error_rows=%s); reporting " + "failed=%s", + obj.id, + obj.status, + total, + successful, + error_rows, + error_rows, + ) + return error_rows + return total - successful def get_aggregated_total_pages_processed(self, obj: WorkflowExecution) -> int | None: """Return the total pages processed across all file executions.""" diff --git a/backend/workflow_manager/execution/tests/__init__.py b/backend/workflow_manager/execution/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/workflow_manager/execution/tests/test_execution_serializer.py b/backend/workflow_manager/execution/tests/test_execution_serializer.py new file mode 100644 index 0000000000..f26370aeab --- /dev/null +++ b/backend/workflow_manager/execution/tests/test_execution_serializer.py @@ -0,0 +1,144 @@ +"""ExecutionSerializer file-count reconciliation (UN-3657). + +A terminal *failure* run (ERROR/STOPPED) must not report files as still "in +progress": the UI derives in-progress as ``total - successful - failed``, so +every non-successful file in a finished failure run has to land in ``failed`` — +including files that never got a ``file_execution`` row. COMPLETED and live +(PENDING/EXECUTING) runs keep the exact row-count behaviour. + +DB-free and app-registry-free: the heavy ``WorkflowExecution`` model is stubbed +in ``sys.modules`` before importing the serializer (mirrors +``usage_v2/tests/test_helper.py`` / ``pg_queue/tests/test_producer.py``), so no +``django.setup()`` / live DB is needed — the methods under test are pure. The +real ``ExecutionStatus`` enum (no Django) is kept for its ``is_failure`` logic. +""" + +from __future__ import annotations + +import logging +import os +import sys +import types +from unittest.mock import MagicMock + +# Force (not setdefault): a dev shell that already exports DJANGO_SETTINGS_MODULE +# (backend.settings.dev / .cloud) would otherwise redirect collection to a module +# that may not exist here. DRF reads settings lazily; no django.setup() is run. +os.environ["DJANGO_SETTINGS_MODULE"] = "backend.settings.test" + +# Stub the model module so importing the serializer needs no app registry / DB. +_models_stub = types.ModuleType("workflow_manager.workflow_v2.models") +_models_stub.WorkflowExecution = type("WorkflowExecution", (), {}) +sys.modules["workflow_manager.workflow_v2.models"] = _models_stub + +from workflow_manager.execution.serializer.execution import ( # noqa: E402 + ExecutionSerializer, +) +from workflow_manager.workflow_v2.enums import ExecutionStatus # noqa: E402 + +_LOGGER = "workflow_manager.execution.serializer.execution" +_ids = iter(range(1, 100_000)) + + +def _execution(status, total_files, *, completed=0, errored=0): + """A WorkflowExecution stand-in with fixed per-status row counts and a unique + id (so the serializer's per-(id, status) count memo never collides across + cases). + """ + obj = MagicMock() + obj.id = f"exec-{next(_ids)}" + obj.status = status + obj.total_files = total_files + + def _filter(status=None, **_kwargs): + result = MagicMock() + result.count.return_value = { + ExecutionStatus.COMPLETED.value: completed, + ExecutionStatus.ERROR.value: errored, + }.get(status, 0) + return result + + obj.file_executions.filter.side_effect = _filter + return obj + + +def _assert_no_phantom_in_progress(serializer, obj): + """The real contract: a finished run leaves zero files "in progress".""" + in_progress = ( + obj.total_files + - serializer.get_successful_files(obj) + - serializer.get_failed_files(obj) + ) + assert in_progress == 0 + + +class TestFailedFileReconciliation: + def test_error_run_with_no_rows_counts_all_as_failed(self): + # ERROR before any file_execution row exists (the observed case). + s = ExecutionSerializer() + obj = _execution(ExecutionStatus.ERROR.value, 2) + assert s.get_successful_files(obj) == 0 + assert s.get_failed_files(obj) == 2 # was 0 -> phantom "2 in progress" + _assert_no_phantom_in_progress(s, obj) + + def test_error_run_partial_rows_reconciles_unaccounted(self): + # total 3: 1 completed, 1 errored row, 1 never created -> 2 failed. + s = ExecutionSerializer() + obj = _execution(ExecutionStatus.ERROR.value, 3, completed=1, errored=1) + assert s.get_failed_files(obj) == 2 + _assert_no_phantom_in_progress(s, obj) + + def test_stopped_run_reconciles(self): + # STOPPED is a failure terminal state: 3 of 5 done -> 2 failed. + s = ExecutionSerializer() + obj = _execution(ExecutionStatus.STOPPED.value, 5, completed=3) + assert s.get_failed_files(obj) == 2 + _assert_no_phantom_in_progress(s, obj) + + def test_completed_run_uses_row_count_unchanged(self): + s = ExecutionSerializer() + clean = _execution(ExecutionStatus.COMPLETED.value, 2, completed=2) + assert s.get_failed_files(clean) == 0 + partial = _execution(ExecutionStatus.COMPLETED.value, 2, completed=1, errored=1) + assert s.get_failed_files(partial) == 1 + + def test_completed_run_not_reconciled_even_with_unaccounted(self): + # Deliberate asymmetry: a COMPLETED run is NOT reconciled — failed stays + # the ERROR-row count, not total - completed. Pins the decision so a + # future "reconcile everything" change fails here. + s = ExecutionSerializer() + obj = _execution(ExecutionStatus.COMPLETED.value, 3, completed=1, errored=1) + assert s.get_failed_files(obj) == 1 # ERROR rows, NOT total-completed (=2) + + def test_live_run_uses_row_count_unchanged(self): + # In-flight run keeps real-time row counts — genuinely-pending files are + # NOT prematurely marked failed. + s = ExecutionSerializer() + obj = _execution(ExecutionStatus.EXECUTING.value, 2) + assert s.get_failed_files(obj) == 0 + + def test_drift_does_not_under_report_below_error_rows(self, caplog): + # total_files stale/low vs real rows: 3 completed + 2 error, total=2. + # Must NOT report 0 (the naive total-successful); keep the 2 real ERROR + # rows and warn — the inverse of the phantom bug. + s = ExecutionSerializer() + obj = _execution(ExecutionStatus.ERROR.value, 2, completed=3, errored=2) + with caplog.at_level(logging.WARNING, logger=_LOGGER): + assert s.get_failed_files(obj) == 2 + assert any("exceed total_files" in r.message for r in caplog.records) + + def test_successful_over_total_clamps_with_warning(self, caplog): + # Impossible count (more completed than total) — clamp to the safe value + # but log the anomaly rather than erasing it silently. + s = ExecutionSerializer() + obj = _execution(ExecutionStatus.ERROR.value, 1, completed=2) + with caplog.at_level(logging.WARNING, logger=_LOGGER): + assert s.get_failed_files(obj) == 0 + assert any(r.levelno == logging.WARNING for r in caplog.records) + + def test_unknown_status_returns_error_rows_and_never_raises(self): + # is_failure swallows ValueError -> else (row-count) path. A malformed + # status must never raise (a raise would 500 the executions list). + s = ExecutionSerializer() + obj = _execution("GARBAGE", 5, completed=1, errored=1) + assert s.get_failed_files(obj) == 1 # ERROR-row count, no reconcile, no raise From c214bf2ae7a7a86d12957714b87c9e850017bca4 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:44:51 +0530 Subject: [PATCH 48/65] =?UTF-8?q?UN-3659=20[FIX]=20PG=20result=20backend?= =?UTF-8?q?=20=E2=80=94=20reconnect-retry=20on=20stale=20store=5Fresult=20?= =?UTF-8?q?connection=20(#2129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3659 [FIX] PG result backend — reconnect-retry on stale store_result connection A PG execution hung permanently (exec b11ba2f3) after the worker sat idle ~56 min (flag off, then enabled + run — past PgBouncer server_idle_timeout). All files extracted + answered prompts fine, then the executor's PgResultBackend.store_result() INSERT hit a connection PgBouncer had silently reaped -> 'server closed the connection unexpectedly' -> result dropped (consumer acked anyway) -> the blocking file-processing callers waited forever and were SIGKILLed -> execution orphaned in EXECUTING. The executor consumer caches one PgResultBackend for its lifetime, so its write connection is long-lived and idle-reapable. _cursor discards a dead connection so the NEXT call reconnects, but store_result()'s CURRENT call failed with no retry. This is the sibling of UN-3654, which added the reconnect-retry to dispatch send() and the consumer claim connection but NOT to this result-write path. Fix: store_result() now retries once on a connection-level error. Safe to retry unconditionally (no reused-vs-fresh guard, unlike send()) because the write is INSERT ... ON CONFLICT (task_id) DO NOTHING — re-running can't duplicate or clobber. _cursor already drops the dead conn, so the retry reconnects and writes the result, unblocking the caller. get_result is left as-is (its only caller makes a fresh PgResultBackend per wait and polls frequently — no idle window). PG transport only. Dev-tested with a real pg_terminate_backend A/B (bare INSERT fails on the reaped conn; store_result self-heals + the row lands). 6 unit tests (retry+succeed, both Operational/Interface error types, backoff fires, retry-also- fails raises once, non-connection error not retried). Related follow-up: the PgBarrier decrement/claim_batch has the same idle-reap risk (left non-retried by UN-3651) — separate ticket. Co-Authored-By: Claude Opus 4.8 * UN-3659 [FIX] address PR #2129 review — shared _CONN_DEAD_ERRORS, typing, owned-only retry, real-PG test - extract _CONN_DEAD_ERRORS shared by _cursor + _store_with_reconnect (parity with the sibling PgQueueClient UN-3654; stops the two sites drifting) - type operation as Callable[[Any], None] (was Any) - retry only OWNED connections: _cursor clears a dead handle only when _owns_conn, so an injected conn can't be reconnected — short-circuit re-raise (no spurious 0.5s sleep). Production is always owned. - WARNING describes the observation, not an inferred cause; exc_info carries type+message, no duplicate inline log - get_result docstring: anchor 'no retry needed' to the real invariant - tests: parametrize retry over completed/failed, commit-time-reap retry, injected-not-retried, and a DB-gated REAL reconnect test (close -> heal -> row) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../queue_backend/pg_queue/result_backend.py | 94 +++++++- workers/tests/test_pg_result_backend.py | 213 +++++++++++++++++- 2 files changed, 296 insertions(+), 11 deletions(-) diff --git a/workers/queue_backend/pg_queue/result_backend.py b/workers/queue_backend/pg_queue/result_backend.py index 0968c1521e..f84f352219 100644 --- a/workers/queue_backend/pg_queue/result_backend.py +++ b/workers/queue_backend/pg_queue/result_backend.py @@ -24,7 +24,11 @@ Connection discipline mirrors :class:`~queue_backend.pg_queue.client.PgQueueClient`: an injected connection is the caller's (tests); otherwise one is created lazily from the backend ``DB_*`` env and owned here (rolled back on error, discarded + -reconnected when it goes bad). +reconnected when it goes bad). The executor consumer caches one backend for its +lifetime, so the write connection sits idle between tasks and PgBouncer can reap +it; :meth:`store_result` therefore retries once on a dead connection so the +result is still written (and the blocking caller unblocked) rather than dropped +— see :meth:`_store_with_reconnect`. """ from __future__ import annotations @@ -32,8 +36,9 @@ import contextlib import json import logging -from collections.abc import Iterator -from typing import TYPE_CHECKING, Any, Self +import time +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Any, Final, Self import psycopg2 @@ -48,6 +53,17 @@ logger = logging.getLogger(__name__) +# psycopg2 errors that mean the connection itself is dead (dropped socket / +# PgBouncer recycle / server termination). Shared by ``_cursor`` (decides whether +# to discard the cached handle) and ``_store_with_reconnect`` (decides whether the +# one-shot retry is eligible) so the two can't drift — narrowing one without the +# other would silently break the retry's "was this a connection death?" test. +# Mirrors ``PgQueueClient._CONN_DEAD_ERRORS`` (UN-3654), the sibling this models on. +_CONN_DEAD_ERRORS: Final[tuple[type[Exception], ...]] = ( + psycopg2.OperationalError, + psycopg2.InterfaceError, +) + # How long a stored result lives before the reaper's retention sweep may delete # it. Defaults to the executor caller-timeout default so a result always # outlives any caller still waiting on it. @@ -85,6 +101,11 @@ def _get_sql() -> str: STATUS_COMPLETED = PgTaskStatus.COMPLETED.value STATUS_FAILED = PgTaskStatus.FAILED.value +# One reconnect-retry for the IDEMPOTENT result write (see _store_with_reconnect). +# Literals (not env-driven) so the one-shot bound can't be widened operationally. +_STORE_RETRY_ATTEMPTS: Final = 2 # total attempts: 1 initial + 1 retry +_STORE_RETRY_BACKOFF_SECONDS: Final = 0.5 + class PgResultBackend: """``store_result`` / ``get_result`` / ``wait_for_result`` over ``pg_task_result``.""" @@ -109,9 +130,7 @@ def _cursor(self) -> Iterator[Any]: yield cur conn.commit() except Exception as exc: - conn_dead = isinstance( - exc, (psycopg2.OperationalError, psycopg2.InterfaceError) - ) + conn_dead = isinstance(exc, _CONN_DEAD_ERRORS) try: conn.rollback() except Exception: @@ -129,6 +148,51 @@ def _cursor(self) -> Iterator[Any]: self._conn = None raise + def _store_with_reconnect(self, operation: Callable[[Any], None]) -> None: + """Run an idempotent store ``operation(cur)`` in a committed cursor, + retrying ONCE if the cached connection was reaped while idle. + + The executor consumer caches one ``PgResultBackend`` for its lifetime + (``consumer.py``), so this connection sits idle BETWEEN tasks and can be + dropped server-side (PgBouncer ``server_idle_timeout`` / failover) — and + ``conn.closed`` is a client-side flag only. ``_cursor``'s discard only + heals the *next* call, so without a retry the *current* ``store_result`` + fails, the result is silently dropped, and the blocking caller is + stranded forever (the exec-b11ba2f3 hang). On a dead-connection error + ``_cursor`` (when it OWNS the connection) has already dropped the handle, + so the retry reconnects. + + Only **owned** connections are retried: ``_cursor`` clears a dead handle + only when ``_owns_conn`` (an injected connection is the caller's), so for + an injected connection the retry would re-acquire the same dead handle and + buy nothing — re-raise immediately. Production is always owned (the + executor consumer constructs ``PgResultBackend()``). + + Safe to retry unconditionally (no reused-vs-fresh guard needed, unlike + the non-idempotent ``PgQueueClient.send``): the write is + ``INSERT … ON CONFLICT (task_id) DO NOTHING``, so re-running after an + ambiguous failure can neither duplicate nor clobber a recorded result. + """ + for attempt in range(1, _STORE_RETRY_ATTEMPTS + 1): + try: + with self._cursor() as cur: + operation(cur) + return + except _CONN_DEAD_ERRORS: + # Last attempt, or an injected (non-owned) conn _cursor won't + # have dropped — retrying can't reconnect, so re-raise. + if attempt >= _STORE_RETRY_ATTEMPTS or not self._owns_conn: + raise + # Describe what was observed, not an inferred cause: this catch + # also covers deadlock / statement-timeout / admin-shutdown, not + # only a stale idle reap. exc_info carries the type + message. + logger.warning( + "PgResultBackend: result write failed with a connection-level " + "error; dropping the cached connection and retrying once", + exc_info=True, + ) + time.sleep(_STORE_RETRY_BACKOFF_SECONDS) + def store_result( self, task_id: str, @@ -141,23 +205,35 @@ def store_result( ``result`` (a dict, e.g. ``ExecutionResult.to_dict()``) → ``completed``. Otherwise → ``failed`` with ``error`` text. Idempotent: a second write - for the same key (at-least-once redelivery) is a no-op. + for the same key (at-least-once redelivery) is a no-op. Survives a stale + cached connection via a one-shot reconnect-retry (see + :meth:`_store_with_reconnect`). """ if result is not None: status, result_json, error_text = STATUS_COMPLETED, json.dumps(result), "" else: status, result_json, error_text = STATUS_FAILED, None, error or "" - with self._cursor() as cur: - cur.execute( + self._store_with_reconnect( + lambda cur: cur.execute( _store_sql(), (str(task_id), status, result_json, error_text, retention_seconds), ) + ) def get_result(self, task_id: str) -> dict[str, Any] | None: """Return ``{status, result, error}`` if the row exists, else ``None``. ``result`` is the decoded JSONB dict (psycopg2 parses ``jsonb`` to a Python ``dict``); ``None`` means the task has not finished yet. + + No reconnect-retry here (unlike :meth:`store_result`). The invariant it + relies on: the sole entry point into a wait (``executor_rpc.wait_for_result`` + → this class's :meth:`wait_for_result` → ``get_result``) constructs a + FRESH ``PgResultBackend`` per wait and polls it every ~0.2–2s, so this + connection is new and kept warm — no idle window to be reaped, and a + failure on a fresh connection is a genuine error, not a stale reap. If a + long-lived backend ever gains a one-shot ``get_result`` lookup, revisit + this. """ with self._cursor() as cur: cur.execute(_get_sql(), (str(task_id),)) diff --git a/workers/tests/test_pg_result_backend.py b/workers/tests/test_pg_result_backend.py index a18ca012d0..10a565fc5b 100644 --- a/workers/tests/test_pg_result_backend.py +++ b/workers/tests/test_pg_result_backend.py @@ -11,11 +11,13 @@ import threading import time import uuid +from unittest.mock import MagicMock +import psycopg2 import pytest - from queue_backend.pg_queue.connection import create_pg_connection from queue_backend.pg_queue.result_backend import ( + _STORE_RETRY_BACKOFF_SECONDS, STATUS_COMPLETED, STATUS_FAILED, PgResultBackend, @@ -83,7 +85,8 @@ def test_wait_times_out_returns_none(self, result_backend): def test_wait_picks_up_late_write_from_other_conn(self, result_backend): """The real request-reply path: the waiter polls on its connection while - the result is committed from a separate connection mid-wait.""" + the result is committed from a separate connection mid-wait. + """ k = _key() os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") writer = PgResultBackend(conn=create_pg_connection(env_prefix="TEST_DB_")) @@ -101,3 +104,209 @@ def write_after_delay() -> None: writer.close() assert row is not None assert row["result"] == {"late": True} + + +# --- Unit: store_result reconnect-retry on a stale cached connection (UN-3659) --- + + +class _CursorCtx: + """Mimic a psycopg2 cursor used as a context manager.""" + + def __init__(self, cursor): + self._cursor = cursor + + def __enter__(self): + return self._cursor + + def __exit__(self, *_): + return False + + +class TestStoreResultReconnectRetry: + """store_result self-heals a connection PgBouncer reaped while the executor + sat idle — the exec-b11ba2f3 hang. Idempotent (ON CONFLICT), so the retry is + unconditional (no reused-vs-fresh guard, unlike PgQueueClient.send). + """ + + @staticmethod + def _conn(*, execute_side_effect=None): + cur = MagicMock() + if execute_side_effect is not None: + cur.execute.side_effect = execute_side_effect + conn = MagicMock() + conn.closed = 0 + conn.cursor.return_value = _CursorCtx(cur) + return conn, cur + + @staticmethod + def _no_sleep(monkeypatch): + sleep = MagicMock() + monkeypatch.setattr("queue_backend.pg_queue.result_backend.time.sleep", sleep) + return sleep + + # Both outcomes go through the same write path — the PR's whole point is that + # a *failed* task's result (error=) is delivered too, not silently dropped. + @pytest.mark.parametrize( + "outcome", + [{"result": {"ok": True}}, {"error": "boom"}], + ids=["completed", "failed"], + ) + def test_stale_conn_retries_and_writes_result(self, monkeypatch, outcome): + # Cached owned conn reaped while idle fails its first INSERT; the one-shot + # retry reconnects (factory) and the result is written + committed. + dead, _ = self._conn(execute_side_effect=psycopg2.OperationalError("idle reap")) + fresh, fresh_cur = self._conn() + factory = MagicMock(return_value=fresh) + monkeypatch.setattr( + "queue_backend.pg_queue.result_backend.create_pg_connection", factory + ) + self._no_sleep(monkeypatch) + rb = PgResultBackend() # owns its connection + rb._conn = dead # simulate a cached (reused) connection + + rb.store_result("k", **outcome) + + dead.close.assert_called_once() # stale conn discarded + factory.assert_called_once() # reconnected exactly once + fresh_cur.execute.assert_called_once() # the retry INSERT ran + fresh.commit.assert_called_once() + + def test_commit_time_reap_retries(self, monkeypatch): + # An idle reap usually surfaces when the buffered INSERT flushes at + # conn.commit() (after the cursor yield), not at execute() — that path + # must retry too. + dead, _ = self._conn() + dead.commit.side_effect = psycopg2.OperationalError("reaped at commit") + fresh, _ = self._conn() + monkeypatch.setattr( + "queue_backend.pg_queue.result_backend.create_pg_connection", + MagicMock(return_value=fresh), + ) + self._no_sleep(monkeypatch) + rb = PgResultBackend() + rb._conn = dead + + rb.store_result("k", result={"ok": True}) # must self-heal + dead.close.assert_called_once() + fresh.commit.assert_called_once() + + def test_injected_connection_not_retried(self, monkeypatch): + # An injected (caller-owned) conn is never discarded by _cursor, so a + # retry would re-acquire the same dead handle — short-circuit to re-raise + # without the spurious backoff sleep. + conn, _ = self._conn(execute_side_effect=psycopg2.OperationalError("dead")) + factory = MagicMock() + monkeypatch.setattr( + "queue_backend.pg_queue.result_backend.create_pg_connection", factory + ) + sleep = self._no_sleep(monkeypatch) + rb = PgResultBackend(conn=conn) # injected -> not owned + + with pytest.raises(psycopg2.OperationalError): + rb.store_result("k", result={"ok": True}) + factory.assert_not_called() + sleep.assert_not_called() + conn.close.assert_not_called() # caller's connection untouched + + @pytest.mark.parametrize( + "exc_type", [psycopg2.OperationalError, psycopg2.InterfaceError] + ) + def test_retries_both_connection_dead_error_types(self, monkeypatch, exc_type): + # InterfaceError ("connection already closed") is the other stale symptom; + # narrowing the except to OperationalError alone would re-break the fix. + dead, _ = self._conn(execute_side_effect=exc_type("dead")) + fresh, _ = self._conn() + monkeypatch.setattr( + "queue_backend.pg_queue.result_backend.create_pg_connection", + MagicMock(return_value=fresh), + ) + self._no_sleep(monkeypatch) + rb = PgResultBackend() + rb._conn = dead + + rb.store_result("k", result={"ok": True}) # must not raise + fresh.commit.assert_called_once() + + def test_backoff_fires_once_on_retry(self, monkeypatch): + dead, _ = self._conn(execute_side_effect=psycopg2.OperationalError("reap")) + fresh, _ = self._conn() + monkeypatch.setattr( + "queue_backend.pg_queue.result_backend.create_pg_connection", + MagicMock(return_value=fresh), + ) + sleep = self._no_sleep(monkeypatch) + rb = PgResultBackend() + rb._conn = dead + + rb.store_result("k", result={"ok": True}) + sleep.assert_called_once_with(_STORE_RETRY_BACKOFF_SECONDS) + + def test_retry_also_fails_reraises_after_one_reconnect(self, monkeypatch): + dead1, _ = self._conn(execute_side_effect=psycopg2.OperationalError("reap")) + dead2, _ = self._conn(execute_side_effect=psycopg2.OperationalError("still")) + factory = MagicMock(return_value=dead2) + monkeypatch.setattr( + "queue_backend.pg_queue.result_backend.create_pg_connection", factory + ) + self._no_sleep(monkeypatch) + rb = PgResultBackend() + rb._conn = dead1 + + with pytest.raises(psycopg2.OperationalError): + rb.store_result("k", result={"ok": True}) + factory.assert_called_once() # exactly one reconnect, no loop + + def test_non_connection_error_not_retried(self, monkeypatch): + # A logical error (not Operational/Interface) is not a stale-conn symptom. + bad, _ = self._conn(execute_side_effect=RuntimeError("logic")) + factory = MagicMock() + monkeypatch.setattr( + "queue_backend.pg_queue.result_backend.create_pg_connection", factory + ) + sleep = self._no_sleep(monkeypatch) + rb = PgResultBackend() + rb._conn = bad + + with pytest.raises(RuntimeError): + rb.store_result("k", result={"ok": True}) + factory.assert_not_called() + sleep.assert_not_called() + + +class TestStoreResultRealReconnect: + """DB-gated: store_result REALLY reconnects against live PG (not just mock + orchestration). Skips when Postgres is unreachable (pg_conn fixture). This is + the test that fails if _cursor stopped nulling _conn or the reconnect handle + were unusable — the mock tests can't see that. + """ + + def test_real_reconnect_heals_closed_connection(self, pg_conn, monkeypatch): + # The owned backend reconnects via result_backend.create_pg_connection; + # point that at the integration DB (TEST_DB_*), the same one pg_conn uses + # (the bare DB_* env is the suite's unit-isolation placeholder). + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + monkeypatch.setattr( + "queue_backend.pg_queue.result_backend.create_pg_connection", + lambda *a, **k: create_pg_connection(env_prefix="TEST_DB_"), + ) + + key = _key() + rb = PgResultBackend() # owned, real connection to the test DB + try: + _ = rb.conn # materialise the connection + rb._conn.close() # client-side close -> InterfaceError on next use + rb.store_result(key, result={"healed": True}) # must self-heal + finally: + rb.close() + + # The row really landed — read it back on the fixture's own connection. + pg_conn.rollback() # clear any aborted txn before reading + with pg_conn.cursor() as cur: + cur.execute( + "SELECT result->>'healed' FROM pg_task_result WHERE task_id = %s", + (key,), + ) + row = cur.fetchone() + cur.execute("DELETE FROM pg_task_result WHERE task_id = %s", (key,)) + pg_conn.commit() + assert row is not None and row[0] == "true" From 4ffc41b01d9986e6b4f187b2b9a228bac3cb6114 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:41:09 +0530 Subject: [PATCH 49/65] =?UTF-8?q?UN-3660=20[FIX]=20PG=20barrier=20decremen?= =?UTF-8?q?t=20=E2=80=94=20phase-split=20reconnect-retry=20on=20stale=20co?= =?UTF-8?q?nnection=20(#2130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3660 [FIX] PG barrier decrement — phase-split reconnect-retry on stale connection The barrier decrement was the last uncovered idle-reap hang path (after UN-3654 dispatch send, UN-3651 enqueue, UN-3659 store_result). Its try/except caught only psycopg2.DataError, so a PgBouncer-reaped cached connection failed the UPDATE, propagated to the in-body abort, and tore the whole execution down on a transient blip (all files COMPLETED, execution ERROR/stuck). The decrement is non-idempotent and a double-apply is harmful (fires the callback early with partial results, or skips past 0 and strands the barrier), so a blind retry — or even send()'s reused-guard alone — is unsafe: a reused-conn failure at commit time is ambiguous. Instead split the statement into its two phases and retry only the safe one: - EXECUTE phase fails on a reused (cached) conn → idle-reap, never committed → reconnect and re-apply exactly once. - COMMIT phase fails → ambiguous (server may have applied it) → never retry; propagate so the caller tears the barrier down (fail-fast, unchanged). Safety rests on the connection being non-autocommit (verified): an execute-phase failure is never durable even if the server ran the UPDATE. Adds the shared _CONN_DEAD_ERRORS constant (parity with client.py / result_backend.py) and a _recover_after_error helper now reused by _cursor. Tests: 6 unit (execute-retry, commit-no-retry, fresh-conn-no-retry, DataError-no-retry, both error types, one-shot bound), a real-DB exactly-once decrement, and a real pg_terminate_backend run asserting the callback fires exactly once. Co-Authored-By: Claude Opus 4.8 * UN-3660 [FIX] Address PR review — fix reused-guard wiring, hoist shared constant Addresses the PR Review Toolkit findings on #2130: - reused-guard was inoperative in production: `_barrier_pg_decrement` runs the entry-guard `_get_conn()` before `_apply_decrement`, so `reused` (sampled inside the loop) was always True — the fresh-conn-not-retried case never fired. Now sample it in `_barrier_pg_decrement` BEFORE the guard and thread it in. Adds an end-to-end test through the wrapper for the fresh-conn case (the prior direct-call test bypassed the guard → false confidence). - Hoist `_CONN_DEAD_ERRORS` to `pg_queue.connection` (the module all three sites import) and import it in client.py / result_backend.py / pg_barrier.py, so the three copies can't drift. (psycopg2 import drops out of the two that only used it for the constant.) - `_recover_after_error`: log the rollback failure with exc_info before flipping `conn_dead` — it was silently swallowed and can reclassify a non-conn error. - Non-autocommit invariant: soften the docstring ("psycopg2's default; not overridden") and pin it at the source with `test_create_pg_connection_is_non_autocommit` (a runtime assert in `_apply_decrement` would break the autocommit test fixture). - Return a `_DecrementRow` NamedTuple instead of `tuple[int, Any]` so the caller reads `.remaining` / `.results`. - Log the execute-phase give-up (fresh-conn death / retry exhausted) — the retryable and commit paths logged but this terminal path was silent. - Commit-phase docstring: name both caller paths (in-body teardown vs Celery .link reclaim-at-expiry) instead of only run_batch_with_barrier. - Reword the unreachable-raise comment (it's a defensive runtime guard, not a type-checker aid) and assert the backoff sleep fires exactly on retry. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/queue_backend/pg_barrier.py | 226 +++++++++++++-- workers/queue_backend/pg_queue/client.py | 16 +- workers/queue_backend/pg_queue/connection.py | 13 + .../queue_backend/pg_queue/result_backend.py | 17 +- workers/tests/test_pg_barrier.py | 272 +++++++++++++++++- 5 files changed, 491 insertions(+), 53 deletions(-) diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 235229f528..23b68a2a3d 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -63,7 +63,7 @@ import time from collections.abc import Callable, Iterator from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, NamedTuple import psycopg2 import psycopg2.extensions @@ -83,6 +83,7 @@ WorkloadType, ) from .handle import BarrierHandle +from .pg_queue.connection import CONN_DEAD_ERRORS as _CONN_DEAD_ERRORS from .pg_queue.connection import create_pg_connection from .pg_queue.schema import qualified @@ -94,6 +95,12 @@ logger = logging.getLogger(__name__) +# ``_CONN_DEAD_ERRORS`` (the "is this a connection death?" test, used by +# ``_recover_after_error``/``_cursor`` to discard a stale handle and by the +# enqueue/decrement retries to decide eligibility) is imported from +# ``pg_queue.connection`` so the dispatch/result/barrier sites can't drift. + + # Thread-local owned connection (prefork → one per child; thread-local keeps it # correct under -P threads too, since a libpq connection is not concurrency-safe # across threads). Self-recovers a dropped socket / PgBouncer recycle — same @@ -109,6 +116,38 @@ def _get_conn() -> PgConnection: return conn +def _recover_after_error(conn: PgConnection, exc: BaseException) -> bool: + """Roll back ``conn``; if it is dead, discard the thread-local handle so the + next :func:`_get_conn` reconnects. Returns whether the connection was judged + dead (a failed rollback proves it dead regardless of the original error). + + Factored out so ``_cursor`` and the decrement phase-split (which can't use + ``_cursor`` because it must distinguish execute-phase from commit-phase + failures) share one definition of "recover a connection after an error". + """ + conn_dead = isinstance(exc, _CONN_DEAD_ERRORS) + try: + conn.rollback() + except Exception: + # A failed rollback proves the connection is unusable regardless of the + # original error's subclass — treat it as dead. Surface why (instead of + # swallowing it): this also reclassifies a non-conn error (e.g. DataError) + # whose rollback fails into the dead/retry path, so the trail must show it + # was the rollback, not the original error, that condemned the connection. + logger.warning( + "PgBarrier: rollback during error recovery failed (original error %s) " + "— treating the connection as dead and discarding it.", + type(exc).__name__, + exc_info=True, + ) + conn_dead = True + if conn_dead or conn.closed: + with contextlib.suppress(Exception): + conn.close() + _local.conn = None + return conn_dead + + @contextlib.contextmanager def _cursor() -> Iterator[Any]: """Yield a cursor; commit on success, roll back + recover on error.""" @@ -118,15 +157,7 @@ def _cursor() -> Iterator[Any]: yield cur conn.commit() except Exception as exc: - conn_dead = isinstance(exc, (psycopg2.OperationalError, psycopg2.InterfaceError)) - try: - conn.rollback() - except Exception: - conn_dead = True - if conn_dead or conn.closed: - with contextlib.suppress(Exception): - conn.close() - _local.conn = None + _recover_after_error(conn, exc) raise @@ -146,6 +177,11 @@ def _cursor() -> Iterator[Any]: # errors the broad psycopg2.OperationalError catch also covers. _BARRIER_RETRY_BACKOFF_SECONDS: Final = 0.5 +# One retry for the NON-idempotent barrier DECREMENT — but only on an +# execute-phase failure on a reused connection (see :func:`_apply_decrement`), +# never on commit (ambiguous). Same one-shot bound as the idempotent write. +_BARRIER_DECREMENT_ATTEMPTS: Final = 2 # total attempts: 1 initial + 1 retry + def _run_idempotent_pre_dispatch_write( operation: Callable[[PgCursor], None], *, what: str @@ -174,7 +210,7 @@ def _run_idempotent_pre_dispatch_write( with _cursor() as cur: operation(cur) return - except (psycopg2.OperationalError, psycopg2.InterfaceError) as exc: + except _CONN_DEAD_ERRORS as exc: # _cursor already dropped the dead thread-local conn → the next # _get_conn() reconnects. Retry once; re-raise if it still fails # (a genuinely-down DB surfaces as ERROR, as before). Name the real @@ -597,6 +633,133 @@ def _fire_barrier_callback( return str(callback_signature.apply_async().id) +class _DecrementRow(NamedTuple): + """The decrement ``UPDATE … RETURNING`` row, named so the caller reads + ``row.remaining`` / ``row.results`` instead of positional ``row[0]`` / ``row[1]`` + and the ``results`` list-shape is documented in the type. + """ + + remaining: int + results: list[Any] + + +def _apply_decrement( + execution_id: str, result_json: str, *, reused: bool +) -> _DecrementRow | None: + """Apply the barrier decrement ``UPDATE`` and return its ``(remaining, + results)`` row (``None`` if the barrier row is already gone). + + The decrement is NON-idempotent — re-applying it double-counts (premature + callback fire with incomplete results, or a strand past 0) — so it is split + into its two phases and retried in ONLY the one phase where a re-apply is + provably safe: + + - **EXECUTE phase** (``UPDATE … RETURNING`` + ``fetchone``) — the statement + runs but is NOT yet committed. A connection-level failure here when the + connection was *cached* (``reused`` — sampled by the caller BEFORE the entry + guard materialises a connection) is the PgBouncer idle-reap: the statement + never reached the server, no commit was issued, and the open transaction is + rolled back on disconnect. So the decrement provably never landed → + reconnect and re-apply it exactly once. A *freshly-created* connection + failing (``reused`` False) is a genuine DB error, not a reap, so it is NOT + retried — a reconnect would buy nothing. **This safety relies on the + connection being non-autocommit** (psycopg2's default; ``create_pg_connection`` + does not override it): even if the server actually ran the ``UPDATE`` before + the socket dropped, it sits in an uncommitted transaction that is rolled back + on disconnect — durability happens ONLY at the commit below — so re-applying + can never double-count. (``test_create_pg_connection_is_non_autocommit`` pins + that default at its source.) + - **COMMIT phase** — ``conn.commit()``. A failure here is AMBIGUOUS: the + server may have applied the commit before the socket dropped. Re-applying + could double-count, so it is NEVER retried — it propagates. On the in-body + PG path (:func:`run_batch_with_barrier`) the caller then tears the barrier + down so the execution fails fast; on the Celery ``.link`` path + (:func:`barrier_pg_decr_and_check`, ``max_retries=0``) it is not retried and + the barrier is reclaimed at ``expires_at`` by the reaper. Either way the + counter is never corrupted. + + Any non-connection error (e.g. the NUL-byte ``psycopg2.DataError`` from the + jsonb cast) also propagates unchanged — only the idle-reap self-heals. This is + the decrement's counterpart to ``pg_queue.client.send``'s reused-guard + (UN-3654) and the idempotent pre-dispatch write's retry, but phase-split + because the decrement, unlike those, must never replay a committed write. + """ + sql = ( + f"UPDATE {qualified('pg_barrier_state')} " + " SET remaining = remaining - 1, " + " results = results || jsonb_build_array(%s::jsonb) " + " WHERE execution_id = %s " + "RETURNING remaining, results" + ) + for attempt in range(1, _BARRIER_DECREMENT_ATTEMPTS + 1): + conn = _get_conn() + try: + with conn.cursor() as cur: + cur.execute(sql, (result_json, execution_id)) + fetched = cur.fetchone() + except Exception as exc: + conn_dead = _recover_after_error(conn, exc) + # Retry ONLY a reused-conn death on the execute phase: it never + # committed (re-applying lands exactly once) and reconnecting can + # actually help. ``reused`` reflects the connection state at entry, so + # it is True only on the first attempt's cached handle; a reconnect + # makes the next attempt fresh, and the attempt bound stops the loop. + if conn_dead and reused and attempt < _BARRIER_DECREMENT_ATTEMPTS: + logger.warning( + "[exec:%s] PgBarrier decrement: execute failed on a cached " + "connection (%s) — reconnecting and re-applying once " + "(attempt %d/%d); the decrement never committed, so it lands " + "exactly once.", + execution_id, + type(exc).__name__, + attempt, + _BARRIER_DECREMENT_ATTEMPTS, + exc_info=True, + ) + time.sleep(_BARRIER_RETRY_BACKOFF_SECONDS) + continue + # Not retried. A non-connection error (e.g. DataError) is logged by the + # caller's own teardown; but a conn-dead give-up (fresh-conn death, or + # the retry budget spent) would otherwise be silent — log it so the + # terminal "didn't self-heal" decision leaves the same breadcrumb the + # retryable path does, then propagate. + if conn_dead: + logger.warning( + "[exec:%s] PgBarrier decrement: execute failed with a " + "connection error (%s) that is NOT being retried (reused=%s, " + "attempt %d/%d) — propagating; the barrier is not self-healed " + "here.", + execution_id, + type(exc).__name__, + reused, + attempt, + _BARRIER_DECREMENT_ATTEMPTS, + exc_info=True, + ) + raise + try: + conn.commit() + except Exception as exc: + _recover_after_error(conn, exc) + # AMBIGUOUS — the server may have committed. Re-applying could + # double-count, so do NOT retry: propagate (the caller fails the + # barrier fast, or it is reclaimed at expiry) rather than corrupt it. + logger.warning( + "[exec:%s] PgBarrier decrement: commit failed (%s) — NOT retrying " + "(the server may already have applied it; a re-apply would " + "double-count). Propagating.", + execution_id, + type(exc).__name__, + exc_info=True, + ) + raise + return None if fetched is None else _DecrementRow(int(fetched[0]), fetched[1]) + # Defensive: the loop always returns or raises before here. The annotation + # permits None, so a type checker would NOT catch a stray fall-through — this + # guards a future edit that breaks the always-return/raise invariant. + raise AssertionError("unreachable: _apply_decrement loop exited without return") + + def _barrier_pg_decrement( result: Any, *, @@ -616,13 +779,29 @@ def _barrier_pg_decrement( the header task's body — a PG-consumed task fires no ``.link``, so the decrement must run in-body. - Callers MUST run each decrement in its own committed transaction (the - decrement ``UPDATE`` runs in its own ``_cursor()`` txn) and MUST NOT retry - it: a replay re-runs the decrement and corrupts the count. This is enforced - loudly, not just in prose — entry raises if the shared connection is already - mid-transaction (see the guard below). The Celery wrapper additionally pins - ``max_retries=0``; an orphaned barrier is bounded by ``expires_at`` instead. + Callers MUST run each decrement in its own committed transaction and MUST NOT + replay a *committed* decrement: re-applying one that already landed corrupts + the count (fires the callback early with incomplete results, or skips past 0 + and strands the barrier). This is enforced loudly, not just in prose — entry + raises if the shared connection is already mid-transaction (see the guard + below), and the Celery wrapper pins ``max_retries=0`` so a task-level replay + can't re-drive it; an orphaned barrier is bounded by ``expires_at`` instead. + + The decrement DOES self-heal one narrow, provably-safe case via + :func:`_apply_decrement`: an execute-phase failure on a cached connection that + PgBouncer idle-reaped (the statement never reached the server, so nothing + committed) is reconnected and re-applied exactly once. A commit-phase failure + is ambiguous and is NEVER retried — see :func:`_apply_decrement`. """ + # Sample whether the decrement will run on a *cached* connection BEFORE the + # entry-guard's _get_conn() below materialises one. _apply_decrement's + # reused-guard needs the pre-guard state: a freshly-created connection must NOT + # be classified 'reused' (only a cached handle can be a stale idle-reap), but + # the guard's _get_conn() would otherwise always leave _local.conn populated, + # making 'reused' permanently True. Mirrors pg_queue.client.send, which samples + # before any _get_conn(). + conn_was_cached = getattr(_local, "conn", None) is not None and not _local.conn.closed + # Enforce the "own committed transaction" contract loudly. A caller that # invokes this inside an already-open transaction on the shared thread-local # connection would hold the row lock across the call and let the outer txn's @@ -656,16 +835,7 @@ def _barrier_pg_decrement( # jsonb_build_array(...) appends exactly one element regardless of the # result's shape (``||`` would concatenate if the result were itself a list). try: - with _cursor() as cur: - cur.execute( - f"UPDATE {qualified('pg_barrier_state')} " - " SET remaining = remaining - 1, " - " results = results || jsonb_build_array(%s::jsonb) " - " WHERE execution_id = %s " - "RETURNING remaining, results", - (result_json, execution_id), - ) - row = cur.fetchone() + row = _apply_decrement(execution_id, result_json, reused=conn_was_cached) except psycopg2.DataError: # json.dumps accepts a few bytes jsonb rejects — notably a NUL (0x00) # in a string. The cast above then raises, the decrement never lands, and @@ -689,7 +859,7 @@ def _barrier_pg_decrement( ) return {"status": "abandoned", "remaining": None} - remaining, all_results = int(row[0]), row[1] + remaining, all_results = row.remaining, row.results logger.info(f"[exec:{execution_id}] PgBarrier decrement → remaining={remaining}") if remaining > 0: diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py index c53b95953e..a3f153a87d 100644 --- a/workers/queue_backend/pg_queue/client.py +++ b/workers/queue_backend/pg_queue/client.py @@ -34,9 +34,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, Self -import psycopg2 - from ..fairness import DEFAULT_PRIORITY, MAX_PRIORITY, MIN_PRIORITY +from .connection import CONN_DEAD_ERRORS as _CONN_DEAD_ERRORS from .connection import create_pg_connection from .schema import qualified @@ -46,16 +45,9 @@ logger = logging.getLogger(__name__) -# psycopg2 errors that mean the connection itself is dead (dropped socket / -# PgBouncer recycle / server termination) rather than a statement-level fault. -# Shared by ``_cursor`` (decides whether to discard the cached handle) and -# ``send`` (decides whether the one-shot reconnect-retry is eligible) so the two -# sites can't drift — narrowing one without the other would silently break the -# retry's "was this a connection death?" test. -_CONN_DEAD_ERRORS: Final[tuple[type[Exception], ...]] = ( - psycopg2.OperationalError, - psycopg2.InterfaceError, -) +# ``_CONN_DEAD_ERRORS`` (the "is this a connection death?" test, shared by +# ``_cursor`` discarding the cached handle and ``send`` deciding retry eligibility) +# is imported from ``.connection`` so the dispatch/result/barrier sites can't drift. # Atomic claim. Takes up to %(qty)s ready messages no other transaction diff --git a/workers/queue_backend/pg_queue/connection.py b/workers/queue_backend/pg_queue/connection.py index 89e5999c5f..4933b420cc 100644 --- a/workers/queue_backend/pg_queue/connection.py +++ b/workers/queue_backend/pg_queue/connection.py @@ -27,6 +27,19 @@ logger = logging.getLogger(__name__) +# psycopg2 errors that mean the connection itself is dead (dropped socket / +# PgBouncer recycle / server termination), as opposed to a logical/data error on +# a live connection. Single source of truth for every PG-queue site that decides +# "was this a connection death?" — the dispatch ``send`` reused-guard +# (``pg_queue.client``, UN-3654), the ``store_result`` retry +# (``pg_queue.result_backend``, UN-3659) and the barrier enqueue/decrement +# (``pg_barrier``, UN-3651/UN-3660). Hoisted here (the module all of them already +# import) so the three call sites can't drift apart. +CONN_DEAD_ERRORS: tuple[type[Exception], ...] = ( + psycopg2.OperationalError, + psycopg2.InterfaceError, +) + # Bounded retry for *transient* connect failures (DB restart, PgBouncer pool # wait, brief network partition, "too many clients" spikes) — the common cloud # blips. Opening a connection is side-effect-free, so retrying it is safe and diff --git a/workers/queue_backend/pg_queue/result_backend.py b/workers/queue_backend/pg_queue/result_backend.py index f84f352219..3185999978 100644 --- a/workers/queue_backend/pg_queue/result_backend.py +++ b/workers/queue_backend/pg_queue/result_backend.py @@ -40,11 +40,10 @@ from collections.abc import Callable, Iterator from typing import TYPE_CHECKING, Any, Final, Self -import psycopg2 - from unstract.core.data_models import PgTaskStatus from unstract.core.polling import poll_for_row +from .connection import CONN_DEAD_ERRORS as _CONN_DEAD_ERRORS from .connection import create_pg_connection from .schema import qualified @@ -53,16 +52,10 @@ logger = logging.getLogger(__name__) -# psycopg2 errors that mean the connection itself is dead (dropped socket / -# PgBouncer recycle / server termination). Shared by ``_cursor`` (decides whether -# to discard the cached handle) and ``_store_with_reconnect`` (decides whether the -# one-shot retry is eligible) so the two can't drift — narrowing one without the -# other would silently break the retry's "was this a connection death?" test. -# Mirrors ``PgQueueClient._CONN_DEAD_ERRORS`` (UN-3654), the sibling this models on. -_CONN_DEAD_ERRORS: Final[tuple[type[Exception], ...]] = ( - psycopg2.OperationalError, - psycopg2.InterfaceError, -) +# ``_CONN_DEAD_ERRORS`` (the "is this a connection death?" test, shared by +# ``_cursor`` discarding the cached handle and ``_store_with_reconnect`` deciding +# retry eligibility) is imported from ``.connection`` so the dispatch/result/barrier +# sites can't drift. # How long a stored result lives before the reaper's retention sweep may delete # it. Defaults to the executor caller-timeout default so a result always diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index 7d032a3b19..afdacad4df 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -16,6 +16,7 @@ from unittest.mock import MagicMock, patch import psycopg2 +import psycopg2.extensions import pytest from queue_backend import barrier as barrier_mod from queue_backend import pg_barrier @@ -135,11 +136,14 @@ def __exit__(self, *exc): class _FakeConn: """Minimal psycopg2-connection stub. ``execute_error`` (if set) is raised by every cursor.execute on this connection — simulating a stale/dead socket. + ``commit_error`` (if set) is raised by ``commit()`` after the execute lands — + the ambiguous "reaped during commit" case the decrement must NOT retry. """ - def __init__(self, *, execute_error=None): + def __init__(self, *, execute_error=None, commit_error=None): self.closed = False self._execute_error = execute_error + self._commit_error = commit_error self.commits = 0 self.rollbacks = 0 self.executes = 0 @@ -154,6 +158,8 @@ def _on_execute(*_a, **_k): def commit(self): self.commits += 1 + if self._commit_error is not None: + raise self._commit_error def rollback(self): self.rollbacks += 1 @@ -161,6 +167,11 @@ def rollback(self): def close(self): self.closed = True + def get_transaction_status(self): + # The decrement entry-guard checks this; a stub conn is always idle (each + # _cursor use commits), so it never trips the open-transaction guard. + return psycopg2.extensions.TRANSACTION_STATUS_IDLE + @pytest.fixture def _clean_local(): @@ -250,6 +261,193 @@ def test_reraises_after_exhausting_attempts(self, _clean_local, monkeypatch): ) +class TestDecrementPhaseSplitRetry: + """`_apply_decrement` is the NON-idempotent barrier decrement, so unlike the + idempotent pre-dispatch write it CANNOT retry freely. It self-heals exactly + one provably-safe case — an execute-phase failure on a *cached* connection + (the PgBouncer idle-reap: the statement never reached the server, so nothing + committed) — and refuses every other: a commit-phase failure is ambiguous + (the server may have applied it) and a fresh-conn failure is a real DB error. + The count is therefore never double-applied. (UN-3660) + """ + + @pytest.fixture + def sleeps(self, monkeypatch): + # Record (and skip) the retry backoff so a test can assert it fired exactly + # when — and only when — a retry happens. Deleting the time.sleep in + # _apply_decrement, or a refactor that starts hammering a struggling DB, + # changes this list. + calls: list[float] = [] + monkeypatch.setattr(pg_barrier.time, "sleep", calls.append) + return calls + + @pytest.mark.parametrize( + "exc", + [ + psycopg2.OperationalError("server closed the connection unexpectedly"), + psycopg2.InterfaceError("connection already closed"), + ], + ids=["OperationalError", "InterfaceError"], + ) + def test_execute_phase_reaped_cached_conn_retries_once( + self, _clean_local, monkeypatch, caplog, sleeps, exc + ): + # The idle-reap: a cached conn fails mid-execute (never committed), so the + # decrement reconnects and re-applies exactly once on a healthy conn. + dead = _FakeConn(execute_error=exc) + healthy = _FakeConn() + pg_barrier._local.conn = dead # cached → "reused" + monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: healthy) + + with caplog.at_level("WARNING"): + pg_barrier._apply_decrement("exec-1", '{"ok": true}', reused=True) + + assert dead.executes == 1 and healthy.executes == 1 # exactly one extra try + assert dead.commits == 0 # the reaped attempt never committed + assert dead.closed is True # stale conn discarded + assert healthy.commits == 1 # committed exactly once, on the retry + assert pg_barrier._local.conn is healthy + assert sleeps == [pg_barrier._BARRIER_RETRY_BACKOFF_SECONDS] # backoff fired once + assert "execute failed on a cached connection" in caplog.text + assert type(exc).__name__ in caplog.text + + def test_commit_phase_failure_is_not_retried( + self, _clean_local, monkeypatch, sleeps + ): + # A commit failure is AMBIGUOUS (the server may have applied it) → must + # NOT retry, or the decrement could land twice. It propagates; the dead + # conn is discarded; no reconnect is attempted. + commit_err = psycopg2.OperationalError("server closed during commit") + conn = _FakeConn(commit_error=commit_err) + pg_barrier._local.conn = conn + reconnects = [] + monkeypatch.setattr( + pg_barrier, + "create_pg_connection", + lambda **_k: reconnects.append(1) or _FakeConn(), + ) + + with pytest.raises(psycopg2.OperationalError, match="during commit"): + pg_barrier._apply_decrement("exec-2", '{"ok": true}', reused=True) + + assert conn.executes == 1 # the UPDATE ran exactly once + assert conn.commits == 1 # commit was attempted exactly once + assert reconnects == [] # NEVER reconnected/retried after a commit failure + assert conn.closed is True # the dead conn was discarded + assert sleeps == [] # no backoff — a commit failure is not retried + + def test_fresh_conn_execute_failure_is_not_retried( + self, _clean_local, monkeypatch, sleeps + ): + # reused=False (the production wrapper passes this when no conn was cached + # before the entry guard). A failure on a fresh conn is a genuine DB error, + # not an idle-reap, so the reused-guard skips the retry — even though it is + # a connection-dead error — and it surfaces immediately. + err = psycopg2.OperationalError("db down") + conn = _FakeConn(execute_error=err) + pg_barrier._local.conn = conn + reconnects = [] + monkeypatch.setattr( + pg_barrier, + "create_pg_connection", + lambda **_k: reconnects.append(1) or _FakeConn(), + ) + + with pytest.raises(psycopg2.OperationalError, match="db down"): + pg_barrier._apply_decrement("exec-3", '{"ok": true}', reused=False) + + assert reconnects == [] # fresh-conn death is not retried + assert sleeps == [] # …so no backoff either + + def test_non_connection_error_is_not_retried( + self, _clean_local, monkeypatch, sleeps + ): + # A DataError (e.g. a NUL byte rejected by the jsonb cast) is not a + # connection death → propagate immediately so the caller tears the barrier + # down. A live conn after a logical error is NOT discarded. + conn = _FakeConn(execute_error=psycopg2.DataError("invalid byte 0x00")) + pg_barrier._local.conn = conn + reconnects = [] + monkeypatch.setattr( + pg_barrier, + "create_pg_connection", + lambda **_k: reconnects.append(1) or _FakeConn(), + ) + + with pytest.raises(psycopg2.DataError): + pg_barrier._apply_decrement("exec-4", '{"ok": true}', reused=True) + + assert reconnects == [] # no retry on a logical/data error + assert conn.closed is False # a live conn after a data error is kept + assert sleeps == [] # not retried → no backoff + + def test_reraises_after_one_retry(self, _clean_local, monkeypatch, sleeps): + # The one-shot bound: a reused-conn idle-reap retries ONCE; if the + # reconnect target also dies on execute, re-raise rather than loop. On + # attempt 2 the attempt bound (attempt < _BARRIER_DECREMENT_ATTEMPTS) is + # what refuses the next retry — note reused is still True (the caller's + # entry-time value), so bumping the attempt constant would NOT add + # self-heals unless the reconnect logic also re-evaluated freshness. + err = psycopg2.OperationalError("still down") + pg_barrier._local.conn = _FakeConn(execute_error=err) + reconnects = [] + monkeypatch.setattr( + pg_barrier, + "create_pg_connection", + lambda **_k: reconnects.append(1) or _FakeConn(execute_error=err), + ) + + with pytest.raises(psycopg2.OperationalError, match="still down"): + pg_barrier._apply_decrement("exec-5", '{"ok": true}', reused=True) + + assert len(reconnects) == 1 # exactly one reconnect, then gave up + assert sleeps == [pg_barrier._BARRIER_RETRY_BACKOFF_SECONDS] # one backoff + + def test_wrapper_fresh_conn_not_retried_end_to_end( + self, _clean_local, monkeypatch, sleeps + ): + # Through the PRODUCTION entry (_barrier_pg_decrement), not _apply_decrement + # directly: with no cached conn, the entry-guard's _get_conn() creates a + # fresh one — but _barrier_pg_decrement samples `reused` BEFORE the guard, + # so it threads reused=False and a genuine fresh-conn DB error is NOT + # retried. Pins the sample-before-the-guard wiring the direct-call tests + # can't see (the guard would otherwise leave _local.conn always populated). + err = psycopg2.OperationalError("db down") + creates = [] + monkeypatch.setattr( + pg_barrier, + "create_pg_connection", + lambda **_k: creates.append(c := _FakeConn(execute_error=err)) or c, + ) + pg_barrier._local.conn = None # nothing cached → wrapper samples reused=False + + with pytest.raises(psycopg2.OperationalError, match="db down"): + _barrier_pg_decrement( + {"f": 1}, execution_id="exec-FRESH", callback_descriptor=_CALLBACK + ) + + assert len(creates) == 1 # the guard created one; NO retry reconnect + assert sleeps == [] # a fresh-conn death is not retried → no backoff + + +def test_create_pg_connection_is_non_autocommit(): + """The decrement phase-split's exactly-once safety rests on the connection + being non-autocommit (an uncommitted UPDATE rolls back on disconnect, so an + execute-phase failure is never durable). Pin that at its SOURCE — a future + ``conn.autocommit = True`` in ``create_pg_connection`` would silently + reintroduce the double-count the split exists to prevent, and no other test + would catch it (the barrier_db fixture sets autocommit itself).""" + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + try: + conn = create_pg_connection(env_prefix="TEST_DB_") + except psycopg2.OperationalError as exc: + pytest.skip(f"Postgres not reachable: {exc}") + try: + assert conn.autocommit is False + finally: + conn.close() + + # --- Layer 2: enqueue + link/abort with a real injected connection --- @@ -460,6 +658,78 @@ def test_pending_decrements_only(self, barrier_db): assert remaining == 2 assert results == [{"f": 1}] + def test_idle_reaped_conn_self_heals_and_decrements_exactly_once( + self, barrier_db, monkeypatch + ): + # End-to-end through the production entry (_barrier_pg_decrement): the + # decrement's cached conn was idle-reaped and fails the first execute; the + # phase-split retry reconnects to the REAL db and lands the decrement + # EXACTLY once (2 → 1, not 2 → 0, and one result appended). Reverting + # _apply_decrement to a plain `with _cursor()` makes this fail. + monkeypatch.setattr(pg_barrier.time, "sleep", lambda *_a, **_k: None) + _seed(barrier_db, "exec-HEALD", 2) + dead = _FakeConn( + execute_error=psycopg2.OperationalError( + "server closed the connection unexpectedly" + ) + ) + pg_barrier._local.conn = dead # the barrier_db fixture conn is the target + monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: barrier_db) + + out = _barrier_pg_decrement( + {"f": "x"}, execution_id="exec-HEALD", callback_descriptor=_CALLBACK + ) + + assert out["status"] == "pending" and out["remaining"] == 1 + assert dead.closed is True # stale conn discarded + assert _row(barrier_db, "exec-HEALD") == (1, [{"f": "x"}]) # decremented once + + def test_pg_terminate_backend_mid_decrement_fires_callback_once( + self, barrier_db, monkeypatch + ): + # The strongest pin: a REAL server-side connection kill (the + # pg_terminate_backend analog of a PgBouncer idle-reap) mid-decrement. The + # victim is a genuine non-autocommit connection (production posture); we + # terminate its backend for real, then drive the decrement that takes the + # barrier to 0. The phase-split retry must reconnect to live PG, land the + # decrement EXACTLY once, and fire the callback EXACTLY once. A + # double-count would drive remaining to -1 → "abandoned" → no callback, so + # asserting "complete" + one signature call + row deleted proves + # exactly-once against a real terminated socket. + monkeypatch.setattr(pg_barrier.time, "sleep", lambda *_a, **_k: None) + _seed(barrier_db, "exec-PTB", 1) # last batch: decrement → 0 fires callback + + victim = create_pg_connection(env_prefix="TEST_DB_") # non-autocommit + with victim.cursor() as cur: + cur.execute("SELECT pg_backend_pid()") + victim_pid = cur.fetchone()[0] + victim.rollback() # return to IDLE so the decrement entry-guard passes + pg_barrier._local.conn = victim # the decrement uses this cached conn + + # Kill the victim's backend from the admin (fixture) connection — the + # client still thinks it's open (conn.closed == 0), exactly like an idle + # reap; the failure surfaces on the next statement (the decrement UPDATE). + with barrier_db.cursor() as cur: + cur.execute("SELECT pg_terminate_backend(%s)", (victim_pid,)) + # The retry reconnects via create_pg_connection; in tests that must target + # the reachable TEST_DB_ (the bare DB_* host is the in-container name). + monkeypatch.setattr( + pg_barrier, + "create_pg_connection", + lambda **_k: create_pg_connection(env_prefix="TEST_DB_"), + ) + + with patch("celery.current_app.signature") as sig: + sig.return_value.apply_async.return_value = MagicMock(id="cb-ptb") + out = barrier_pg_decr_and_check( + {"f": "x"}, execution_id="exec-PTB", callback_descriptor=_CALLBACK + ) + + assert out["status"] == "complete" # reached 0 exactly once (not -1) + sig.assert_called_once() # callback fired exactly once + assert _row(barrier_db, "exec-PTB") is None # barrier torn down after fire + assert victim.closed # the terminated conn was discarded by the retry + def test_complete_fires_callback_with_aggregated_results(self, barrier_db): _seed(barrier_db, "exec-C", 1, results='[{"f": "a"}]') with patch("celery.current_app.signature") as sig: From f080a095573a27b99bbef80798a9b69285e6e0a0 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:07:23 +0530 Subject: [PATCH 50/65] =?UTF-8?q?UN-3661=20[FIX]=20PG=20reaper=20=E2=80=94?= =?UTF-8?q?=20fast=20stuck-batch=20detection=20+=20file-execution=20cascad?= =?UTF-8?q?e=20(#2134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3661 [FIX] PG reaper — fast stuck-batch detection + file-execution cascade The PG reaper only recovered a stranded execution at the ~6h barrier expiry, and marked the workflow_execution ERROR without touching its file executions — so a crashed batch left the execution stuck EXECUTING for ~6h, then ERROR while its files stayed EXECUTING (the exec-b11ba2f3 UI inconsistency). Part 1 — fast, progress-based detection (Celery per-task time-limit parity): - Add pg_barrier_state.last_progress_at (unindexed), re-stamped to now() on enqueue and on every decrement. Unindexed so the decrement stays a heap-only (HOT) update — no index churn on the hot path; the reaper seq-scans this tiny (in-flight-only) table cheaply, and it's directly queryable for observability. - The reaper marks a barrier stranded once last_progress_at is older than WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS (default 9000 = 2.5h, matching Celery's FILE_PROCESSING_TASK_TIME_LIMIT) OR it passes the fixed 6h expires_at cap. A per-progress window is invariant to MAX_PARALLEL_FILE_BATCHES (dynamic per-org), so it never false-fails a legitimately long multi-batch run. Part 2 — atomic file-execution cascade: - The internal update_status endpoint gains a cascade_terminal_files flag: when set AND the new status is terminal, it also marks the execution's non-terminal (PENDING/EXECUTING) file executions to the same terminal status, in the same transaction (they can't diverge). The reaper sets it on recovery, so a stranded execution and its files reach a consistent terminal state. Tests: barrier enqueue/decrement stamp last_progress_at; reaper OR-detection (stalled reaped, progressing untouched) + cascade flag asserted; backend cascade unit test (non-terminal -> terminal, terminal untouched, idempotent). Dev-tested E2E against real Postgres (reaper detection) and the real DB (cascade). Co-Authored-By: Claude Opus 4.8 * UN-3661 [FIX] Address PR review — reaper robustness, canonical terminal set, tests - Reaper: validate WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS once at PgReaper startup (a garbled value now crashes loudly at boot instead of silently failing every sweep via run()'s generic "cycle failed" catch — the recovery net stays honest). - Reaper: _mark_stranded_error now checks the status-update response.success and raises if falsy (mirrors the read path), so a non-raising success=False can't let the reaper DELETE the barrier — the only recovery handle — while the execution stays non-terminal. - Add ExecutionStatus.terminal_values() (core) as the single source for the terminal set; is_completed() and the file cascade's status__in exclude both use it (behaviour identical — COMPLETED/STOPPED/ERROR). - Serializer: reject cascade_terminal_files=True with a non-terminal status (400) instead of silently no-op'ing. - barrier.py: extract _positive_int_env() shared by barrier_ttl_seconds and barrier_stuck_timeout_seconds (removes the duplicated parser). - Comment accuracy: last_progress_at (not expires_at) is the sliding column; the 2.5h default sits in Celery FILE_PROCESSING_TASK_TIME_LIMIT's band (ships 2h/3h), not an equality. Documented that the cascade bulk-update intentionally doesn't reconcile per-file execution_time / parent aggregates. - Tests: the headline last_progress_at path now has real-Postgres coverage — stale last_progress_at + FUTURE expires_at is reaped (proving the clause is load-bearing), a progressing barrier is NOT reaped, a progress-refresh mid-recovery aborts the mark, and a non-success write does not delete the row. Co-Authored-By: Claude Opus 4.8 * UN-3661 [FIX] Reduce validate() cognitive complexity (SonarQube 16→below 15) Split WorkflowExecutionStatusUpdateSerializer.validate() into a thin dispatcher plus _validate_cascade() and _validate_file_aggregates() helpers, and flatten the total-is-None else into an early return. Behaviour is identical; the added cascade check no longer tips the aggregate validator over the cognitive-complexity limit. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../0011_pgbarrierstate_last_progress_at.py | 23 +++++ backend/pg_queue/models.py | 26 +++-- .../tests/test_cascade_terminal_files.py | 62 ++++++++++++ .../workflow_manager/internal_serializers.py | 64 ++++++++----- backend/workflow_manager/internal_views.py | 46 +++++++++ .../core/src/unstract/core/data_models.py | 16 +++- workers/queue_backend/barrier.py | 66 ++++++++++--- workers/queue_backend/pg_barrier.py | 43 +++++++-- workers/queue_backend/pg_queue/reaper.py | 94 +++++++++++++++---- workers/shared/api/internal_client.py | 8 +- workers/shared/clients/execution_client.py | 6 ++ workers/tests/test_pg_barrier.py | 80 ++++++++++++++++ workers/tests/test_pg_reaper.py | 85 ++++++++++++++++- 13 files changed, 546 insertions(+), 73 deletions(-) create mode 100644 backend/pg_queue/migrations/0011_pgbarrierstate_last_progress_at.py create mode 100644 backend/workflow_manager/execution/tests/test_cascade_terminal_files.py diff --git a/backend/pg_queue/migrations/0011_pgbarrierstate_last_progress_at.py b/backend/pg_queue/migrations/0011_pgbarrierstate_last_progress_at.py new file mode 100644 index 0000000000..f7440ecf47 --- /dev/null +++ b/backend/pg_queue/migrations/0011_pgbarrierstate_last_progress_at.py @@ -0,0 +1,23 @@ +# Generated for UN-3661 — PG barrier stuck-timeout (progress liveness). + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0010_pgtaskresult"), + ] + + operations = [ + # Progress-liveness column for the reaper's fast stuck-detection. Default + # now() so any row created before this migration is treated as fresh. + # Deliberately UNINDEXED — written on every barrier decrement, so an index + # would break the decrement's heap-only-tuple (HOT) update; the reaper + # seq-scans this tiny (in-flight-only) table cheaply. + migrations.AddField( + model_name="pgbarrierstate", + name="last_progress_at", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index e413bd3712..c81fb715df 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -185,9 +185,10 @@ class PgBarrierState(models.Model): drives ``remaining`` to 0 dispatches the aggregating callback and deletes the row. A header-task failure aborts the barrier by deleting the row outright (``DELETE … RETURNING`` — atomic claim+teardown), so the callback can never - fire with partial results. ``expires_at`` bounds an orphaned barrier (header - tasks that never complete); a periodic sweep job (not yet implemented) is the - intended reclaim backstop. + fire with partial results. The ``pg-queue-reaper`` marks a stranded execution + ERROR when ``last_progress_at`` goes stale (no batch completed for + ``WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS``) or, as an absolute backstop, when + ``expires_at`` passes (see ``workers/queue_backend/pg_queue/reaper.py``). Managed=True / generated migration — no DB-side function, extension-free (UN-3533), same posture as ``PgQueueMessage``. @@ -206,10 +207,23 @@ class PgBarrierState(models.Model): # Aggregated header-task results, appended in completion order (JSONB array). results = models.JSONField(default=list) created_at = models.DateTimeField(default=timezone.now) - # Orphan bound (Redis-TTL equivalent): a barrier whose header tasks never - # complete is reclaimable past this. Must exceed the longest execution - # wall-clock, same budgeting as WORKER_BARRIER_KEY_TTL_SECONDS. + # Absolute orphan cap (Redis-TTL equivalent, WORKER_BARRIER_KEY_TTL_SECONDS, + # default 6h): a last-resort backstop the reaper also sweeps. Fixed at enqueue + # — the *fast* stuck signal is last_progress_at below. expires_at = models.DateTimeField() + # Progress liveness (UN-3661): re-stamped to now() on enqueue AND on every + # decrement, so it tracks when a batch last completed. The reaper marks the + # execution ERROR once it is older than WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS + # (default 2.5h — in the same band as Celery's per-task + # FILE_PROCESSING_TASK_TIME_LIMIT, which ships 2h–3h) — a per-PROGRESS window + # that is invariant to MAX_PARALLEL_FILE_BATCHES (dynamic + # per-org), so it never false-fails a legitimately long multi-batch run. + # Deliberately UNINDEXED: it's written on every decrement, so indexing it would + # break the heap-only-tuple (HOT) update the decrement relies on — and the + # reaper's sweep of this tiny (in-flight-only) table is a cheap seq scan. + # Directly queryable ("when did this barrier last progress?") — the PG-queue + # "stuck-detection = plain SQL" property. + last_progress_at = models.DateTimeField(default=timezone.now) class Meta: db_table = "pg_barrier_state" diff --git a/backend/workflow_manager/execution/tests/test_cascade_terminal_files.py b/backend/workflow_manager/execution/tests/test_cascade_terminal_files.py new file mode 100644 index 0000000000..43b61e1f82 --- /dev/null +++ b/backend/workflow_manager/execution/tests/test_cascade_terminal_files.py @@ -0,0 +1,62 @@ +"""WorkflowExecutionInternalViewSet._cascade_terminal_files (UN-3661). + +When the reaper marks a stranded execution terminal with +``cascade_terminal_files``, the execution's non-terminal (PENDING/EXECUTING) file +executions must be marked to the same terminal status atomically — so a recovered +strand never leaves execution=ERROR while its files stay EXECUTING (the b11ba2f3 +inconsistency). Terminal files (COMPLETED/ERROR/STOPPED) are left untouched. +""" + +from django.test import TestCase + +from workflow_manager.file_execution.models import WorkflowFileExecution +from workflow_manager.internal_views import WorkflowExecutionInternalViewSet +from workflow_manager.workflow_v2.enums import ExecutionStatus +from workflow_manager.workflow_v2.models.execution import WorkflowExecution +from workflow_manager.workflow_v2.models.workflow import Workflow + +_cascade = WorkflowExecutionInternalViewSet._cascade_terminal_files + + +class CascadeTerminalFilesTests(TestCase): + def setUp(self): + self.workflow = Workflow.objects.create(workflow_name="test-cascade-wf") + self.execution = WorkflowExecution.objects.create( + workflow=self.workflow, status=ExecutionStatus.EXECUTING + ) + + def _file(self, name, status): + return WorkflowFileExecution.objects.create( + workflow_execution=self.execution, file_name=name, status=status.value + ) + + def _reload(self, fe): + fe.refresh_from_db() + return fe + + def test_cascades_only_non_terminal_files_to_the_terminal_status(self): + executing = self._file("a.pdf", ExecutionStatus.EXECUTING) + pending = self._file("b.pdf", ExecutionStatus.PENDING) + completed = self._file("c.pdf", ExecutionStatus.COMPLETED) + + _cascade(self.execution, ExecutionStatus.ERROR, "boom") + + assert self._reload(executing).status == ExecutionStatus.ERROR.value + assert self._reload(pending).status == ExecutionStatus.ERROR.value + # A file that already finished is left alone (no status overwrite). + assert self._reload(completed).status == ExecutionStatus.COMPLETED.value + assert self._reload(executing).execution_error == "boom" + + def test_non_terminal_target_status_is_a_noop(self): + # The cascade only fires when the execution itself went terminal. + executing = self._file("a.pdf", ExecutionStatus.EXECUTING) + _cascade(self.execution, ExecutionStatus.EXECUTING, "x") + assert self._reload(executing).status == ExecutionStatus.EXECUTING.value + + def test_idempotent_second_run_changes_nothing(self): + executing = self._file("a.pdf", ExecutionStatus.EXECUTING) + _cascade(self.execution, ExecutionStatus.ERROR, "first") + assert self._reload(executing).status == ExecutionStatus.ERROR.value + # Second run: the file is already terminal → excluded → error not clobbered. + _cascade(self.execution, ExecutionStatus.ERROR, "second") + assert self._reload(executing).execution_error == "first" diff --git a/backend/workflow_manager/internal_serializers.py b/backend/workflow_manager/internal_serializers.py index 8c156d3450..9127763873 100644 --- a/backend/workflow_manager/internal_serializers.py +++ b/backend/workflow_manager/internal_serializers.py @@ -175,6 +175,11 @@ class WorkflowExecutionStatusUpdateSerializer(serializers.Serializer): status = serializers.ChoiceField(choices=ExecutionStatus.choices) error_message = serializers.CharField(required=False, allow_blank=True) + # When true AND the new status is terminal, also mark this execution's + # non-terminal (PENDING/EXECUTING) file executions to the same terminal status, + # atomically. The reaper sets this so a stranded execution and its files reach a + # consistent terminal state (else execution=ERROR while its files stay EXECUTING). + cascade_terminal_files = serializers.BooleanField(required=False, default=False) total_files = serializers.IntegerField( required=False, min_value=0 ) # Allow 0 but backend will only update if > 0 @@ -184,11 +189,33 @@ class WorkflowExecutionStatusUpdateSerializer(serializers.Serializer): execution_time = serializers.FloatField(required=False, min_value=0) def validate(self, attrs): - """Reject impossible file-count aggregates. + """Reject a meaningless cascade combo + impossible file-count aggregates.""" + self._validate_cascade(attrs) + self._validate_file_aggregates(attrs) + return attrs - Per-field min_value=0 catches negatives, but successful + failed > - total or either component > total slips through and skews the - outcome-based notification filter downstream. + @staticmethod + def _validate_cascade(attrs) -> None: + # cascade_terminal_files only makes sense when the new status is terminal + # (it's a silent no-op otherwise) — reject the illegal combo at the boundary + # rather than accepting-and-ignoring it. + if attrs.get("cascade_terminal_files") and not ExecutionStatus.is_completed( + attrs["status"] + ): + raise serializers.ValidationError( + { + "cascade_terminal_files": ( + "cascade_terminal_files=True requires a terminal status " + "(COMPLETED/ERROR/STOPPED)." + ) + } + ) + + @staticmethod + def _validate_file_aggregates(attrs) -> None: + """Per-field min_value=0 catches negatives, but successful + failed > total + or either component > total slips through and skews the outcome-based + notification filter downstream. """ total = attrs.get("total_files") successful = attrs.get("successful_files") @@ -201,23 +228,18 @@ def validate(self, attrs): "total_files": "total_files is required when file aggregates are provided." } ) - else: - if successful is not None and successful > total: - raise serializers.ValidationError( - {"successful_files": "successful_files cannot exceed total_files."} - ) - if failed is not None and failed > total: - raise serializers.ValidationError( - {"failed_files": "failed_files cannot exceed total_files."} - ) - if ( - successful is not None - and failed is not None - and successful + failed > total - ): - msg = "successful_files + failed_files cannot exceed total_files." - raise serializers.ValidationError({"non_field_errors": msg}) - return attrs + return + if successful is not None and successful > total: + raise serializers.ValidationError( + {"successful_files": "successful_files cannot exceed total_files."} + ) + if failed is not None and failed > total: + raise serializers.ValidationError( + {"failed_files": "failed_files cannot exceed total_files."} + ) + if successful is not None and failed is not None and successful + failed > total: + msg = "successful_files + failed_files cannot exceed total_files." + raise serializers.ValidationError({"non_field_errors": msg}) class OrganizationContextSerializer(serializers.Serializer): diff --git a/backend/workflow_manager/internal_views.py b/backend/workflow_manager/internal_views.py index 88a0837074..fd3b528e4c 100644 --- a/backend/workflow_manager/internal_views.py +++ b/backend/workflow_manager/internal_views.py @@ -512,6 +512,12 @@ def update_status(self, request, id=None): # File aggregates are not handled by update_execution; persist separately. self._update_file_aggregates(execution, validated_data) + # Reaper recovery: cascade the terminal status to any non-terminal + # file executions so the execution and its files agree (atomic with + # the status write above — they can't diverge). + if validated_data.get("cascade_terminal_files"): + self._cascade_terminal_files(execution, status_enum, error_message) + logger.info( f"Updated workflow execution {id} status to {validated_data['status']}" ) @@ -531,6 +537,46 @@ def update_status(self, request, id=None): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + @staticmethod + def _cascade_terminal_files(execution, status_enum, error_message) -> None: + """Mark this execution's non-terminal (PENDING/EXECUTING) file executions to + *status_enum*, so a reaper-recovered strand doesn't leave the execution + terminal while its files stay EXECUTING (the b11ba2f3 inconsistency). + + No-op unless *status_enum* is terminal. Runs in the caller's transaction, so + the execution status and the file cascade commit together. Idempotent — a + re-run finds those files already terminal and updates nothing. + """ + from workflow_manager.file_execution.models import WorkflowFileExecution + from workflow_manager.workflow_v2.enums import ExecutionStatus + + terminal = ExecutionStatus.terminal_values() # canonical COMPLETED/ERROR/STOPPED + if status_enum.value not in terminal: + return + # Bulk .update() deliberately bypasses WorkflowFileExecution.update_status(): + # this is a give-up recovery, so per-file execution_time is unknown (stays + # NULL) and the parent's failed/successful_files aggregates are NOT + # reconciled here — the execution's own terminal status (set above) is the + # source of truth (is_failure_run / notifications read it), and the + # serializer already derives failed counts for a terminal-failure run. + cascaded = ( + WorkflowFileExecution.objects.filter(workflow_execution=execution) + .exclude(status__in=terminal) + .update( + status=status_enum.value, + execution_error=( + error_message + or "[reaper-recovery] Execution stranded; file marked terminal " + "to match the execution." + ), + ) + ) + if cascaded: + logger.info( + f"Cascaded terminal status {status_enum.value} to {cascaded} " + f"non-terminal file execution(s) of execution {execution.id}" + ) + @staticmethod def _truncate_error_message(error_msg: str | None, execution_id) -> str | None: """Truncate an error message to the 256-char column limit (with ellipsis).""" diff --git a/unstract/core/src/unstract/core/data_models.py b/unstract/core/src/unstract/core/data_models.py index 01f011f5d4..91329e2371 100644 --- a/unstract/core/src/unstract/core/data_models.py +++ b/unstract/core/src/unstract/core/data_models.py @@ -553,12 +553,22 @@ def __repr__(self): ) +# Canonical terminal-status values (COMPLETED/STOPPED/ERROR). One definition so +# every terminal check — is_completed() below, and ORM ``status__in`` filters like +# the reaper's file cascade — stays in sync when a status is added/removed. +def _terminal_values(cls) -> frozenset[str]: + """The string values of the terminal execution statuses.""" + return frozenset({cls.COMPLETED.value, cls.STOPPED.value, cls.ERROR.value}) + + +ExecutionStatus.terminal_values = classmethod(_terminal_values) + + # Add the is_completed method as a class method def _is_completed(cls, status: str) -> bool: - """Check if the execution status represents a completed state.""" + """Check if the execution status represents a completed (terminal) state.""" try: - status_enum = cls(status) - return status_enum in [cls.COMPLETED, cls.STOPPED, cls.ERROR] + return cls(status).value in cls.terminal_values() except ValueError: raise ValueError(f"Invalid status: {status}. Must be a valid ExecutionStatus.") diff --git a/workers/queue_backend/barrier.py b/workers/queue_backend/barrier.py index 0e838242ca..9e5c47dc1e 100644 --- a/workers/queue_backend/barrier.py +++ b/workers/queue_backend/barrier.py @@ -56,31 +56,73 @@ _DEFAULT_BARRIER_TTL_SECONDS = 6 * 60 * 60 # 6h -def barrier_ttl_seconds() -> int: - """Barrier TTL from ``WORKER_BARRIER_KEY_TTL_SECONDS`` (default 6h). - - Read at call time so tests can flip it. Invalid / non-positive values raise, - matching ``get_barrier()``'s loud-on-misconfig posture — a TTL shorter than - execution wall-clock would tear barriers down early (spurious behaviour). +def _positive_int_env(var: str, default: int, human_default: str) -> int: + """Read positive-int env ``var``; return *default* when unset, raise (loud) on a + non-integer or non-positive value. *human_default* is how the default renders in + the error (e.g. ``"21600s (6h)"``). Shared by the two barrier duration knobs so + their parse/validate can't drift. """ - raw = os.getenv("WORKER_BARRIER_KEY_TTL_SECONDS") + raw = os.getenv(var) if raw is None: - return _DEFAULT_BARRIER_TTL_SECONDS + return default try: value = int(raw) except ValueError as exc: raise ValueError( - f"WORKER_BARRIER_KEY_TTL_SECONDS={raw!r} is not an integer. Unset it " - f"to default to {_DEFAULT_BARRIER_TTL_SECONDS}s (6h)." + f"{var}={raw!r} is not an integer. Unset it to default to {human_default}." ) from exc if value <= 0: raise ValueError( - f"WORKER_BARRIER_KEY_TTL_SECONDS={value} must be a positive integer. " - f"Unset it to default to {_DEFAULT_BARRIER_TTL_SECONDS}s (6h)." + f"{var}={value} must be a positive integer. Unset it to default to " + f"{human_default}." ) return value +def barrier_ttl_seconds() -> int: + """Barrier TTL from ``WORKER_BARRIER_KEY_TTL_SECONDS`` (default 6h). + + Read at call time so tests can flip it. Invalid / non-positive values raise, + matching ``get_barrier()``'s loud-on-misconfig posture — a TTL shorter than + execution wall-clock would tear barriers down early (spurious behaviour). + """ + return _positive_int_env( + "WORKER_BARRIER_KEY_TTL_SECONDS", + _DEFAULT_BARRIER_TTL_SECONDS, + f"{_DEFAULT_BARRIER_TTL_SECONDS}s (6h)", + ) + + +# PG barrier stuck-timeout — the "no progress" deadline for a PG-backed barrier. +# The barrier's ``last_progress_at`` column (NOT ``expires_at``) is re-stamped to +# bare ``now()`` on enqueue AND on every decrement; the reaper applies THIS timeout +# at query time (``last_progress_at < now() - stuck_timeout``, see +# ``reaper._STRANDED_PREDICATE``), so a barrier is reaped only after this long with +# NO batch completing. The 9000s (2.5h) default sits in the SAME BAND as Celery's +# per-task ``FILE_PROCESSING_TASK_TIME_LIMIT`` — a tunable env shipping 7200 (2h) / +# 10800 (3h) across the sample deployments — giving crash / runaway parity without +# asserting an equality that rots when an operator tunes that var. A per-PROGRESS +# window is invariant to how many batches run in parallel (``MAX_PARALLEL_FILE_ +# BATCHES`` is dynamic per-org), so — unlike a per-execution age cap — it never +# false-fails a legitimately long multi-batch run: any completion refreshes it. +_DEFAULT_BARRIER_STUCK_TIMEOUT_SECONDS = 9000 # 2.5h — Celery file-proc band (2h–3h) + + +def barrier_stuck_timeout_seconds() -> int: + """PG barrier stuck-timeout from ``WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS`` (default 2.5h). + + Read at call time (tests flip it). Invalid / non-positive values raise, + matching :func:`barrier_ttl_seconds`'s loud-on-misconfig posture — this bounds + how long a stalled execution stays non-terminal, so a garbled value must fail + fast rather than silently disable the recovery net. + """ + return _positive_int_env( + "WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS", + _DEFAULT_BARRIER_STUCK_TIMEOUT_SECONDS, + f"{_DEFAULT_BARRIER_STUCK_TIMEOUT_SECONDS}s (2.5h)", + ) + + class CallbackDescriptor(TypedDict): """Serialisable aggregating-callback spec baked into a barrier link signature. diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 23b68a2a3d..45f1b5531b 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -17,7 +17,8 @@ **Wire model.** 1. ``enqueue``: UPSERT one ``pg_barrier_state`` row (``remaining = N``, - ``results = []``, ``expires_at = now() + ttl``) — the UPSERT clears any stale + ``results = []``, ``expires_at = now() + ttl``, ``last_progress_at = now()``) + — the UPSERT clears any stale state from a prior run reusing the same ``execution_id``. Each header task is dispatched with ``.link(barrier_pg_decr_and_check)`` (success) and @@ -38,10 +39,17 @@ (leaving the row for a sibling to retry) — there is no claimed-but-not-deleted window. Mirrors chord's default error semantic (callback not invoked on header failure). -4. Orphan bound: like the Redis backend's key TTL, ``expires_at`` bounds a - barrier whose header tasks never complete. **No expiry reclaim ships in this - phase** — a periodic sweep job (keyed on ``pg_barrier_expires_idx``) is the - intended backstop and is future work. +4. Stuck bound: ``last_progress_at`` is re-stamped to ``now()`` on enqueue AND on + every decrement, tracking when a batch last completed. The + :mod:`~queue_backend.pg_queue.reaper` marks the stranded execution ERROR once + ``last_progress_at`` is older than ``WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS`` + (default 2.5h — in the same band as Celery's per-task + ``FILE_PROCESSING_TASK_TIME_LIMIT``, which ships 2h–3h) — a + per-PROGRESS window, invariant to how many batches run in parallel + (``MAX_PARALLEL_FILE_BATCHES`` is dynamic per-org), so it never false-fails a + legitimately long multi-batch run. ``expires_at`` (fixed at enqueue, + ``WORKER_BARRIER_KEY_TTL_SECONDS``, default 6h) is the absolute last-resort cap + the reaper also sweeps. **Failure-masking guards.** The callback can only fire when ``remaining`` hits exactly 0, which requires ALL N header tasks to have decremented — i.e. all @@ -400,6 +408,8 @@ def enqueue( # The decrement (run in-body on the PG path) reads this to # self-chain the callback onto PG rather than Celery. callback_descriptor["transport"] = WorkflowTransport.PG_QUEUE.value + # expires_at = absolute orphan cap (6h). last_progress_at = now() (the + # reaper's fast stuck signal, re-stamped on every decrement). ttl_seconds = barrier_ttl_seconds() # Stamp the owning org so the reaper can call the org-scoped status # API when recovering this barrier if it strands (it has only the @@ -424,14 +434,20 @@ def _reset_barrier(cur: PgCursor) -> None: cur.execute( f"INSERT INTO {qualified('pg_barrier_state')} " "(execution_id, organization_id, remaining, results, " - " created_at, expires_at) " + " created_at, expires_at, last_progress_at) " "VALUES (%s, %s, %s, '[]'::jsonb, now(), " - " now() + make_interval(secs => %s)) " + " now() + make_interval(secs => %s), now()) " "ON CONFLICT (execution_id) DO UPDATE SET " " organization_id = EXCLUDED.organization_id, " " remaining = EXCLUDED.remaining, results = '[]'::jsonb, " - " created_at = now(), expires_at = EXCLUDED.expires_at", - (execution_id, organization_id, len(header_tasks), ttl_seconds), + " created_at = now(), expires_at = EXCLUDED.expires_at, " + " last_progress_at = now()", + ( + execution_id, + organization_id, + len(header_tasks), + ttl_seconds, + ), ) # Reset per-batch dedup markers from a prior run reusing this # execution_id, ATOMICALLY with the barrier reset. Without this a @@ -684,10 +700,17 @@ def _apply_decrement( (UN-3654) and the idempotent pre-dispatch write's retry, but phase-split because the decrement, unlike those, must never replay a committed write. """ + # ``last_progress_at = now()`` records that a batch just completed — the + # reaper's stuck signal (it marks the execution ERROR only once no decrement + # has landed for stuck_timeout; see barrier.py / reaper.py). last_progress_at + # is UNINDEXED, so — like remaining/results — this stays a heap-only-tuple (HOT) + # update: no index churn on the decrement hot path. (expires_at, the indexed + # absolute cap, is deliberately NOT touched here, to preserve HOT.) sql = ( f"UPDATE {qualified('pg_barrier_state')} " " SET remaining = remaining - 1, " - " results = results || jsonb_build_array(%s::jsonb) " + " results = results || jsonb_build_array(%s::jsonb), " + " last_progress_at = now() " " WHERE execution_id = %s " "RETURNING remaining, results" ) diff --git a/workers/queue_backend/pg_queue/reaper.py b/workers/queue_backend/pg_queue/reaper.py index 5d6f54b794..96f18eb8ff 100644 --- a/workers/queue_backend/pg_queue/reaper.py +++ b/workers/queue_backend/pg_queue/reaper.py @@ -56,6 +56,7 @@ from unstract.core.data_models import ExecutionStatus +from ..barrier import barrier_stuck_timeout_seconds from .connection import create_pg_connection from .leader_election import LeaderLease, default_worker_id from .liveness import LivenessServer as _BaseLivenessServer @@ -81,6 +82,15 @@ # is comfortably above any single execution. _DEFAULT_DEDUP_RETENTION_SECONDS = 86400 +# A barrier is "stranded" when it has made no progress for the stuck-timeout (the +# fast, per-progress signal — UN-3661) OR it has passed its absolute ``expires_at`` +# cap (the last-resort backstop). Both feed the SAME recovery. Defined once so the +# detection SELECT, the pre-mark re-check, and the cleanup DELETE can't drift. +# Binds one ``%s`` — the stuck-timeout in seconds (``barrier_stuck_timeout_seconds``). +_STRANDED_PREDICATE = ( + "(last_progress_at < now() - make_interval(secs => %s) OR expires_at < now())" +) + _N = TypeVar("_N", int, float) @@ -274,18 +284,20 @@ def _execution_status( return getattr(response, "status", None) -def _still_expired(conn: PgConnection, execution_id: str) -> bool: - """True iff the barrier row is still present AND still past expiry. +def _still_stranded( + conn: PgConnection, execution_id: str, stuck_timeout_seconds: int +) -> bool: + """True iff the barrier row is still present AND still stranded. Re-checked immediately before the ERROR mark so a same-id re-enqueue (UPSERT - resets ``expires_at`` to the future) between the sweep's SELECT and the mark - doesn't get its live run flagged ERROR. + resets both ``expires_at`` to the future AND ``last_progress_at`` to now()) + between the sweep's SELECT and the mark doesn't get its live run flagged ERROR. """ with conn.cursor() as cur: cur.execute( f"SELECT 1 FROM {qualified('pg_barrier_state')} " - "WHERE execution_id = %s AND expires_at < now()", - (execution_id,), + f"WHERE execution_id = %s AND {_STRANDED_PREDICATE}", + (execution_id, stuck_timeout_seconds), ) found = cur.fetchone() is not None conn.commit() @@ -314,12 +326,27 @@ def _mark_stranded_error( "the barrier was already torn down (remaining < 0) yet the execution " "was left non-terminal — inconsistent state" ) - api_client.update_workflow_execution_status( + response = api_client.update_workflow_execution_status( execution_id=execution_id, status=ExecutionStatus.ERROR.value, error_message=f"[reaper-recovery] Execution stranded: {reason}.", organization_id=organization_id, + # Cascade ERROR to the execution's non-terminal file executions in the same + # backend transaction — else the execution goes ERROR while its files stay + # EXECUTING (the b11ba2f3 inconsistency). + cascade_terminal_files=True, ) + # Mirror the read path (_execution_status): the internal client returns an + # APIResponse and may report a failed write via ``success=False`` rather than + # raising. Treat a non-success as a hard failure so the caller does NOT proceed + # to DELETE the barrier row (erasing the only recovery handle while the + # execution stays non-terminal). ``success`` absent → assume raised-on-failure + # legacy contract (True). + if not getattr(response, "success", True): + raise RuntimeError( + f"status update for stranded execution {execution_id} reported " + f"success=False (refusing to delete the barrier recovery handle)" + ) logger.error( "Reaper: marked stranded execution %s ERROR (remaining=%s).", execution_id, @@ -333,6 +360,7 @@ def _recover_one_barrier( execution_id: str, organization_id: str, remaining: int, + stuck_timeout_seconds: int, ) -> bool: """Recover one stranded execution; return True iff its barrier row was deleted. @@ -389,7 +417,7 @@ def _recover_one_barrier( execution_id, status, ) - elif not _still_expired(conn, execution_id): + elif not _still_stranded(conn, execution_id, stuck_timeout_seconds): # Re-armed (same execution_id re-enqueued) between the read and here — # do NOT mark a freshly-running execution ERROR; leave it for the new run. logger.warning( @@ -405,8 +433,8 @@ def _recover_one_barrier( with conn.cursor() as cur: cur.execute( f"DELETE FROM {qualified('pg_barrier_state')} WHERE execution_id = %s " - "AND expires_at < now()", - (execution_id,), + f"AND {_STRANDED_PREDICATE}", + (execution_id, stuck_timeout_seconds), ) deleted = cur.rowcount > 0 if deleted: @@ -425,11 +453,21 @@ def _recover_one_barrier( def recover_expired_barriers( - conn: PgConnection, api_client: InternalAPIClient + conn: PgConnection, + api_client: InternalAPIClient, + stuck_timeout_seconds: int | None = None, ) -> list[str]: - """Recover executions stranded by an expired barrier. Returns recovered ids. + """Recover stranded executions. Returns recovered ids. + + A barrier is stranded when it has made no progress for + ``stuck_timeout_seconds`` (the fast per-progress signal — a crashed worker's + batch, or a runaway) OR it has passed its absolute ``expires_at`` cap; see + :data:`_STRANDED_PREDICATE`. ``stuck_timeout_seconds`` defaults to + :func:`~queue_backend.barrier.barrier_stuck_timeout_seconds` (resolved once + per sweep and threaded through, so the SELECT and the per-row re-check/DELETE + all use the same value). - SELECT the expired rows (the reaper is leader-elected → a single active + SELECT the stranded rows (the reaper is leader-elected → a single active sweeper, so a read-then-act is safe from double-claim), then recover each one best-effort: mark the execution ERROR if it isn't already terminal, then delete its ``pg_batch_dedup`` + ``pg_barrier_state`` rows. One execution @@ -441,11 +479,14 @@ def recover_expired_barriers( ``conn`` runs in manual-commit mode; on any error we roll back before continuing/re-raising so the connection isn't left in an aborted-txn state. """ + if stuck_timeout_seconds is None: + stuck_timeout_seconds = barrier_stuck_timeout_seconds() try: with conn.cursor() as cur: cur.execute( "SELECT execution_id, organization_id, remaining " - f"FROM {qualified('pg_barrier_state')} WHERE expires_at < now()" + f"FROM {qualified('pg_barrier_state')} WHERE {_STRANDED_PREDICATE}", + (stuck_timeout_seconds,), ) rows = cur.fetchall() conn.commit() @@ -459,7 +500,12 @@ def recover_expired_barriers( for execution_id, organization_id, remaining in rows: try: if _recover_one_barrier( - conn, api_client, execution_id, organization_id, remaining + conn, + api_client, + execution_id, + organization_id, + remaining, + stuck_timeout_seconds, ): recovered.append(execution_id) # else: a benign skip (terminal / re-armed / no-status / no-org) — @@ -507,10 +553,22 @@ def __init__( interval_seconds: float | None = None, sweep_interval_seconds: float | None = None, dedup_retention_seconds: int | None = None, + stuck_timeout_seconds: int | None = None, sweep_conn: PgConnection | None = None, api_client: InternalAPIClient | None = None, ) -> None: self._lease = lease + # Resolve + validate the barrier stuck-timeout ONCE, at construction, so a + # garbled WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS crashes loudly at boot rather + # than raising inside every tick (where run()'s generic "cycle failed" catch + # would silently disable the whole recovery net, looking like a DB blip). + self._stuck_timeout_seconds = ( + stuck_timeout_seconds + if stuck_timeout_seconds is not None + else barrier_stuck_timeout_seconds() + ) + if self._stuck_timeout_seconds <= 0: + raise ValueError("stuck_timeout_seconds must be positive") self._interval = ( interval_seconds if interval_seconds is not None @@ -634,7 +692,11 @@ def tick(self) -> TickOutcome: return TickOutcome(was_leader=False, reclaimed=0) try: reclaimed = len( - recover_expired_barriers(self._get_sweep_conn(), self._get_api_client()) + recover_expired_barriers( + self._get_sweep_conn(), + self._get_api_client(), + self._stuck_timeout_seconds, + ) ) except Exception: self._discard_owned_sweep_conn() diff --git a/workers/shared/api/internal_client.py b/workers/shared/api/internal_client.py index 4964a2b377..5af0212d47 100644 --- a/workers/shared/api/internal_client.py +++ b/workers/shared/api/internal_client.py @@ -608,8 +608,13 @@ def update_workflow_execution_status( attempts: int | None = None, execution_time: float | None = None, organization_id: str | None = None, + cascade_terminal_files: bool = False, ) -> dict[str, Any]: - """Update workflow execution status.""" + """Update workflow execution status. + + ``cascade_terminal_files=True`` also marks the execution's non-terminal file + executions to the same terminal status atomically (used by the reaper). + """ return self.execution_client.update_workflow_execution_status( execution_id, status, @@ -620,6 +625,7 @@ def update_workflow_execution_status( attempts, execution_time, organization_id, + cascade_terminal_files=cascade_terminal_files, ) def create_workflow_execution(self, execution_data: dict[str, Any]) -> dict[str, Any]: diff --git a/workers/shared/clients/execution_client.py b/workers/shared/clients/execution_client.py index e1373eb9f5..ffc1104c9b 100644 --- a/workers/shared/clients/execution_client.py +++ b/workers/shared/clients/execution_client.py @@ -270,6 +270,7 @@ def update_workflow_execution_status( attempts: int | None = None, execution_time: float | None = None, organization_id: str | None = None, + cascade_terminal_files: bool = False, ) -> APIResponse: """Update workflow execution status. @@ -283,6 +284,9 @@ def update_workflow_execution_status( attempts: Optional attempts count execution_time: Optional execution time organization_id: Optional organization ID override + cascade_terminal_files: When True and *status* is terminal, the backend + also marks this execution's non-terminal file executions to the same + terminal status, atomically (the reaper uses this on recovery). Returns: APIResponse with update result @@ -292,6 +296,8 @@ def update_workflow_execution_status( data = {"status": status_str} + if cascade_terminal_files: + data["cascade_terminal_files"] = True if error_message is not None: data["error_message"] = error_message if total_files is not None: diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index afdacad4df..8a5fee1864 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -92,6 +92,24 @@ def test_invalid_raises(self, monkeypatch, bad): barrier_mod.barrier_ttl_seconds() +class TestStuckTimeoutEnv: + # The PG barrier's sliding last_progress_at window (UN-3661) — distinct from the + # Redis-shared TTL above. Default 2.5h, in Celery's FILE_PROCESSING band (2h–3h). + def test_default_is_two_and_half_hours(self, monkeypatch): + monkeypatch.delenv("WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS", raising=False) + assert barrier_mod.barrier_stuck_timeout_seconds() == 9000 + + def test_overridable(self, monkeypatch): + monkeypatch.setenv("WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS", "120") + assert barrier_mod.barrier_stuck_timeout_seconds() == 120 + + @pytest.mark.parametrize("bad", ["abc", "0", "-5"]) + def test_invalid_raises(self, monkeypatch, bad): + monkeypatch.setenv("WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS", bad) + with pytest.raises(ValueError, match="WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS"): + barrier_mod.barrier_stuck_timeout_seconds() + + class TestEnqueueShortCircuits: def test_empty_header_returns_none(self): # Returns before any DB / dispatch. @@ -498,6 +516,30 @@ def _org(conn, execution_id): return row[0] if row else None +def _expires_in_seconds(conn, execution_id): + """Seconds from *now* until the barrier's expires_at (DB-side now(), so no + client-clock skew) — the fixed 6h absolute cap.""" + with conn.cursor() as cur: + cur.execute( + "SELECT EXTRACT(EPOCH FROM (expires_at - now())) " + "FROM pg_barrier_state WHERE execution_id = %s", + (execution_id,), + ) + return float(cur.fetchone()[0]) + + +def _last_progress_age_seconds(conn, execution_id): + """How long ago (DB-side) the barrier last made progress — the reaper's stuck + signal (UN-3661). Small = fresh; large = stalled.""" + with conn.cursor() as cur: + cur.execute( + "SELECT EXTRACT(EPOCH FROM (now() - last_progress_at)) " + "FROM pg_barrier_state WHERE execution_id = %s", + (execution_id,), + ) + return float(cur.fetchone()[0]) + + class TestPgBarrierEnqueue: def test_upsert_creates_row_and_attaches_links(self, barrier_db): tasks = [_mock_header_task() for _ in range(3)] @@ -515,6 +557,24 @@ def test_upsert_creates_row_and_attaches_links(self, barrier_db): cloned.link_error.assert_called_once() cloned.apply_async.assert_called_once() + def test_enqueue_sets_expires_cap_and_fresh_progress( + self, barrier_db, monkeypatch + ): + # enqueue stamps expires_at = now()+ttl (the absolute cap) AND + # last_progress_at = now() (fresh), so a just-enqueued barrier is neither + # expired nor stale. (UN-3661) + monkeypatch.setenv("WORKER_BARRIER_KEY_TTL_SECONDS", "600") + task, _ = _mock_header_task() + PgBarrier().enqueue( + [task], + callback_task_name="cb", + callback_kwargs={"execution_id": "exec-SD"}, + callback_queue="general", + app_instance=None, + ) + assert 590 <= _expires_in_seconds(barrier_db, "exec-SD") <= 600 # ~ttl cap + assert _last_progress_age_seconds(barrier_db, "exec-SD") < 5 # fresh + def test_enqueue_self_heals_on_stale_connection(self, barrier_db, monkeypatch): # End-to-end through enqueue(): the first barrier write hits a dead cached # conn; the fix must reconnect to the REAL db, land the row exactly once, @@ -658,6 +718,26 @@ def test_pending_decrements_only(self, barrier_db): assert remaining == 2 assert results == [{"f": 1}] + def test_decrement_refreshes_last_progress_at(self, barrier_db): + # Each decrement re-stamps last_progress_at = now(), so a barrier that IS + # making progress never goes stale (the reaper only reaps a stalled one). + # Seed a STALE last_progress_at (as if about to be reaped) → decrement must + # refresh it. (UN-3661) + with barrier_db.cursor() as cur: + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, organization_id, remaining, results, " + " created_at, expires_at, last_progress_at) " + "VALUES ('exec-SL', '', 2, '[]'::jsonb, " + " now() - interval '1 hour', now() + interval '5 hours', " + " now() - interval '1 hour')" + ) + assert _last_progress_age_seconds(barrier_db, "exec-SL") > 3000 # ~1h stale + barrier_pg_decr_and_check( + {"f": 1}, execution_id="exec-SL", callback_descriptor=_CALLBACK + ) + assert _last_progress_age_seconds(barrier_db, "exec-SL") < 5 # refreshed + def test_idle_reaped_conn_self_heals_and_decrements_exactly_once( self, barrier_db, monkeypatch ): diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py index d36bd42989..9f88af5751 100644 --- a/workers/tests/test_pg_reaper.py +++ b/workers/tests/test_pg_reaper.py @@ -517,12 +517,16 @@ def __init__( fail_read=False, fail_update=False, fail_update_for=None, + update_success=True, on_get=None, ): self._status = status self._fail_read = fail_read self._fail_update = fail_update self._fail_update_for = set(fail_update_for or []) + # NON-raising failure: the real update client returns an APIResponse and can + # report success=False rather than raising (like the read path). + self._update_success = update_success self._on_get = on_get # side-effect hook (e.g. re-arm the row mid-recovery) self.get_calls: list = [] self.update_calls: list = [] @@ -546,11 +550,12 @@ def update_workflow_execution_status( status=status, error_message=error_message, organization_id=organization_id, + cascade_terminal_files=kw.get("cascade_terminal_files", False), ) ) if self._fail_update or execution_id in self._fail_update_for: raise RuntimeError("api down") - return {"status": "updated"} + return SimpleNamespace(success=self._update_success) class TestRecoverConnection: @@ -648,11 +653,22 @@ def barrier_conn(): conn.close() -def _seed(conn, execution_id, *, expired, organization_id="org-1", remaining=1): +def _seed( + conn, + execution_id, + *, + expired, + organization_id="org-1", + remaining=1, + last_progress="now()", +): # created_at must precede expires_at (CheckConstraint # pg_barrier_expires_after_created). Commit so the seed is durable like a # real barrier row (written by PgBarrier in another transaction) — and so the # manual-commit recovery's own commit is what persists the DELETE. + # ``expired`` sets the absolute expires_at cap past/future; ``last_progress`` is + # a raw SQL expr (default "now()" = fresh) so a test can seed a STALE + # last_progress_at to exercise the fast (per-progress) stuck path independently. created_sql, expires_sql = ( ("now() - interval '2 hours'", "now() - interval '1 hour'") if expired @@ -662,8 +678,9 @@ def _seed(conn, execution_id, *, expired, organization_id="org-1", remaining=1): cur.execute( "INSERT INTO pg_barrier_state " "(execution_id, organization_id, remaining, results, " - " created_at, expires_at) " - f"VALUES (%s, %s, %s, '[]'::jsonb, {created_sql}, {expires_sql})", + " created_at, expires_at, last_progress_at) " + f"VALUES (%s, %s, %s, '[]'::jsonb, {created_sql}, {expires_sql}, " + f"{last_progress})", (execution_id, organization_id, remaining), ) conn.commit() @@ -700,6 +717,9 @@ def test_recovers_only_expired_marks_error_and_cleans_up(self, barrier_conn): assert call.status == "ERROR" assert call.organization_id == "org-1" assert "never completed" in call.error_message # remaining>0 + # Cascade the terminal status to the stranded execution's non-terminal + # files (the b11ba2f3 fix) — else execution=ERROR while files stay EXECUTING. + assert call.cascade_terminal_files is True assert _ids(barrier_conn) == ["fresh-1"] # fresh barrier untouched def test_remaining_zero_uses_callback_stranded_message(self, barrier_conn): @@ -788,6 +808,50 @@ def rearm(execution_id): assert api.update_calls == [] # the live re-run was NOT marked ERROR assert _ids(barrier_conn) == ["exp-rearm"] # re-armed row left intact + def test_reaps_stale_last_progress_while_expires_at_future(self, barrier_conn): + # THE headline path (UN-3661): a barrier is reaped via a stale + # last_progress_at even though its absolute expires_at cap is still 6h in + # the FUTURE (a crash mid-run → no 6h wait). And a *progressing* barrier + # (fresh last_progress_at, same future cap) is NOT reaped — proving the + # last_progress_at clause is load-bearing, not just the expires_at clause. + _seed( + barrier_conn, + "stale-lp", + expired=False, # expires_at = now()+6h (future) + remaining=2, + last_progress="now() - interval '1 hour'", # > 120s stuck window + ) + _seed(barrier_conn, "fresh-lp", expired=False, last_progress="now()") + api = _FakeApiClient(status="EXECUTING") + recovered = recover_expired_barriers(barrier_conn, api, 120) # stuck=120s + assert recovered == ["stale-lp"] + (call,) = api.update_calls + assert call.execution_id == "stale-lp" and call.status == "ERROR" + assert call.cascade_terminal_files is True + assert _ids(barrier_conn) == ["fresh-lp"] # progressing barrier untouched + + def test_progress_refresh_mid_recovery_aborts_the_mark(self, barrier_conn): + # The re-arm race for the fast path: a decrement refreshes last_progress_at + # to now() between the sweep SELECT and the mark → the barrier is no longer + # stranded and the reaper must NOT mark its live run ERROR (mirrors the + # expires_at re-arm test, but via last_progress_at). + _seed(barrier_conn, "lp-rearm", expired=False, last_progress="now() - interval '1 hour'") + + def refresh(execution_id): + with barrier_conn.cursor() as cur: + cur.execute( + "UPDATE pg_barrier_state SET last_progress_at = now() " + "WHERE execution_id = %s", + (execution_id,), + ) + barrier_conn.commit() + + api = _FakeApiClient(status="EXECUTING", on_get=refresh) + recovered = recover_expired_barriers(barrier_conn, api, 120) + assert recovered == [] # progress refreshed → not stranded + assert api.update_calls == [] # live run NOT marked ERROR + assert _ids(barrier_conn) == ["lp-rearm"] # row left for the new run + def test_status_none_on_success_does_not_mark(self, barrier_conn): # A successful read with no status is anomalous — don't mark on it. _seed(barrier_conn, "exp-nostatus", expired=True) @@ -797,6 +861,19 @@ def test_status_none_on_success_does_not_mark(self, barrier_conn): assert api.update_calls == [] assert _ids(barrier_conn) == ["exp-nostatus"] # left for next sweep + def test_unsuccessful_status_write_does_not_delete_recovery_handle( + self, barrier_conn + ): + # Defensive (mirrors the read path): if the status-update client ever + # reports success=False WITHOUT raising, the reaper must NOT proceed to + # DELETE the barrier row — that would erase the only recovery handle while + # the execution stays non-terminal forever. + _seed(barrier_conn, "exp-unsuccess", expired=True) + api = _FakeApiClient(status="EXECUTING", update_success=False) + recovered = recover_expired_barriers(barrier_conn, api) + assert recovered == [] # the RuntimeError → row left for the next sweep + assert _ids(barrier_conn) == ["exp-unsuccess"] # recovery handle preserved + def test_all_skipped_sweep_does_not_log_systemic_error(self, barrier_conn, caplog): # org-missing rows are benign skips, not failures — they must NOT trigger # the systemic "recovered NONE / API down" ERROR escalation. From 9e8756cf7f459f4e00db8a65208eb61947473890 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:19:30 +0530 Subject: [PATCH 51/65] =?UTF-8?q?UN-3662=20[FIX]=20PG=20queue=20=E2=80=94?= =?UTF-8?q?=20skip=20stale/redelivered=20batch=20for=20a=20reaper-recovere?= =?UTF-8?q?d=20(terminal)=20execution=20(#2137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3662 [FIX] PG queue — skip stale/redelivered batch for a reaper-recovered (terminal) execution On the PG queue (at-least-once), a file batch can be redelivered — or a stale batch consumed — after the reaper has recovered the execution (marked it ERROR) and deleted its barrier. Reprocessing resurrected the execution back to EXECUTING and re-ran its files (double LLM / destination write), leaving execution=EXECUTING with files=COMPLETED and a contradictory reaper-recovery error_message. Add a validate-first terminal guard in the file-processing batch path: _setup_execution_context raises _TerminalExecutionSkip when the fetched execution is already terminal (COMPLETED/ERROR/STOPPED); _run_batch_stages catches it and returns a benign zero-work result — no status flip, no pre-create, no processing. Gated to the PG path (is_pg) so the Celery flow is byte-for-byte unchanged — the resurrection is PG-specific (Celery lost tasks are never redelivered). Tests: terminal-guard unit tests + transport-routing tests strengthened to assert is_pg=False on Celery / True on PG. Co-Authored-By: Claude Opus 4.8 * UN-3662 [FIX] address review — bypass barrier decrement on terminal skip; count skipped files as failed Review (PR #2137) raised three P2s: 1. (P2, architectural) _run_batch_stages swallowed _TerminalExecutionSkip and returned a normal-looking result, so run_batch_with_barrier still called _barrier_pg_decrement — but in the guard's intended scenario the reaper has already DELETED the barrier, so the decrement hits 'row is None' and logs a spurious ERROR ('PgBarrier decrement found no row — barrier already torn down') on every benign skip: false Sentry/alert noise that also desensitises operators to the same line when it signals real corruption. It also risked driving remaining->0 and firing the aggregating callback with zero-work results in the STOPPED (barrier-still-live) case. Fix: _terminal_skip_result now carries SKIPPED_TERMINAL_EXECUTION_KEY; run_batch_with_barrier detects it and bypasses the decrement entirely (and does not abort) — no gone-barrier round-trip, no spurious ERROR, no zero-work callback. A truly stranded STOPPED barrier is left to the reaper (the existing cleanup net). 2. + 3. (P2, count semantics) The skip result reported total=N, successful=0, failed=0. Per the 68d137a8 accounting contract (unaccounted files count as failed, not in-progress), any consumer that aggregates it would render N files as perpetually in-progress. Fix: report failed_files=N so in-progress (total - successful - failed) is 0 and the files don't silently vanish. Tests: assert failed=N + marker on the skip result; new tests that run_batch_with_barrier bypasses the decrement (and abort) on a marked result and still decrements on a normal result. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/file_processing/tasks.py | 104 ++++++++++- workers/queue_backend/pg_barrier.py | 20 +++ workers/tests/test_chord_callback_boundary.py | 18 +- .../tests/test_terminal_execution_guard.py | 167 ++++++++++++++++++ 4 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 workers/tests/test_terminal_execution_guard.py diff --git a/workers/file_processing/tasks.py b/workers/file_processing/tasks.py index 5afb41ab54..f37c924de9 100644 --- a/workers/file_processing/tasks.py +++ b/workers/file_processing/tasks.py @@ -12,7 +12,10 @@ from queue_backend import worker_task from queue_backend.barrier import BarrierContext -from queue_backend.pg_barrier import run_batch_with_barrier +from queue_backend.pg_barrier import ( + SKIPPED_TERMINAL_EXECUTION_KEY, + run_batch_with_barrier, +) # Import shared worker infrastructure from shared.api import InternalAPIClient @@ -224,8 +227,71 @@ def _enhance_batch_with_mrq_flags( ) +class _TerminalExecutionSkip(Exception): + """A batch was delivered for an execution already in a terminal state. + + Raised by :func:`_setup_execution_context` when the execution's status is + COMPLETED/ERROR/STOPPED. On the PG queue (at-least-once), a batch can be + redelivered — or a stale batch consumed — *after* the reaper has recovered + the execution (marked it ERROR) and deleted its barrier. Reprocessing would + resurrect the execution back to EXECUTING and re-run its files (double LLM / + destination write). The batch is skipped instead (UN-3662). + + The guard is gated to the PG path (``is_pg``) so the Celery flow is + byte-for-byte unchanged — the resurrection it prevents is PG-specific + (Celery lost tasks are never redelivered, so they can't be resurrected). + """ + + def __init__(self, execution_id: str, status: str | None) -> None: + self.execution_id = execution_id + self.status = status + super().__init__( + f"Execution {execution_id} already terminal ({status}); " + f"skipping stale/redelivered batch" + ) + + +def _raise_if_execution_terminal( + workflow_execution: dict[str, Any], execution_id: str, *, is_pg: bool +) -> None: + """PG-path-only validate-first guard: refuse to (re)process a batch whose + execution is already terminal. See :class:`_TerminalExecutionSkip`. + + No-op on the Celery path (``is_pg=False``) — the resurrection this prevents + is PG-specific, so gating keeps the Celery flow byte-for-byte unchanged + (UN-3662). A missing/unknown status is treated as non-terminal (fail open — + proceed as normal). + """ + if not is_pg: + return + status = workflow_execution.get("status") + if status in ExecutionStatus.terminal_values(): + raise _TerminalExecutionSkip(execution_id, status) + + +def _terminal_skip_result(batch_data: FileBatchData) -> dict[str, Any]: + """Batch result for a batch skipped because its execution is already terminal + (UN-3662). The files were never attempted; per the accounting contract from + 68d137a8 (unaccounted files count as failed, not in-progress) they are + reported as failed, so a consumer that ever aggregates this result does not + render them as perpetually in-progress. The ``SKIPPED_TERMINAL_EXECUTION_KEY`` + marker tells ``run_batch_with_barrier`` to bypass the barrier decrement (the + reaper has by definition already torn the barrier down). + """ + n_files = len(batch_data.files) + result = BatchExecutionResult( + total_files=n_files, + successful_files=0, + failed_files=n_files, + execution_time=0.0, + organization_id=batch_data.file_data.organization_id, + ).to_dict() + result[SKIPPED_TERMINAL_EXECUTION_KEY] = True + return result + + def _run_batch_stages( - file_batch_data: dict[str, Any], celery_task_id: str + file_batch_data: dict[str, Any], celery_task_id: str, is_pg: bool = False ) -> dict[str, Any]: """The actual batch work (validate → setup → pre-create → process → compile). @@ -237,8 +303,21 @@ def _run_batch_stages( # Step 1: Validate and parse input data batch_data = _validate_and_parse_batch_data(file_batch_data) - # Step 2: Setup execution context - context = _setup_execution_context(batch_data, celery_task_id) + # Step 2: Setup execution context. Validate-first (PG path only): a batch + # delivered for an already-terminal execution is a stale/redelivered task + # arriving after the reaper recovered the execution — skip it rather than + # resurrect the execution and re-run its files (UN-3662). The guard is gated + # inside _setup_execution_context via is_pg, so on Celery this never fires. + try: + context = _setup_execution_context(batch_data, celery_task_id, is_pg=is_pg) + except _TerminalExecutionSkip as skip: + logger.warning( + f"[exec:{skip.execution_id}] Skipping batch of " + f"{len(batch_data.files)} file(s): execution already terminal " + f"({skip.status}) — stale/redelivered after reaper-recovery; not " + f"reprocessing (UN-3662)." + ) + return _terminal_skip_result(batch_data) # Step 3: Handle manual review logic # context = _handle_manual_review_logic(context) @@ -281,13 +360,16 @@ def _process_file_batch_core( if barrier_context is None: # Celery chord path — the chord's .link runs the decrement after this. - return _run_batch_stages(file_batch_data, celery_task_id) + # is_pg=False disables the UN-3662 terminal guard → Celery flow unchanged. + return _run_batch_stages(file_batch_data, celery_task_id, is_pg=False) # PG fire-and-forget path — claim the batch (idempotent on redelivery), run # the stages, then decrement the barrier in-body / self-chain the callback. + # is_pg=True enables the UN-3662 terminal guard (skip stale/redelivered + # batches for a reaper-recovered execution). return run_batch_with_barrier( barrier_context, - lambda: _run_batch_stages(file_batch_data, celery_task_id), + lambda: _run_batch_stages(file_batch_data, celery_task_id, is_pg=True), ) @@ -359,7 +441,7 @@ def _validate_and_parse_batch_data(file_batch_data: dict[str, Any]) -> FileBatch def _setup_execution_context( - batch_data: FileBatchData, celery_task_id: str + batch_data: FileBatchData, celery_task_id: str, is_pg: bool = False ) -> WorkflowContextData: """Setup execution context with validation and API client initialization. @@ -411,6 +493,14 @@ def _setup_execution_context( execution_context = execution_response.data workflow_execution = execution_context.get("execution", {}) + # UN-3662: validate-first terminal guard (PG path only, gated by is_pg). If + # the execution is already terminal (COMPLETED/ERROR/STOPPED), this batch is + # a stale/redelivered task arriving after the reaper recovered the execution + # — reprocessing would resurrect it to EXECUTING and re-run its files. Skip + # before we touch status or process anything. On Celery (is_pg=False) this is + # a no-op, so the existing Celery flow is unchanged. + _raise_if_execution_terminal(workflow_execution, execution_id, is_pg=is_pg) + # Set LOG_EVENTS_ID in StateStore for WebSocket messaging (critical for UI logs) # This enables the WorkerWorkflowLogger to send logs to the UI via WebSocket execution_log_id = workflow_execution.get("execution_log_id") diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 45f1b5531b..0180585271 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -1037,6 +1037,14 @@ def _abort_barrier_in_body(execution_id: str, *, reason: str) -> None: ) +# A batch whose execution is already terminal (UN-3662) returns its result with +# this marker set. run_batch_with_barrier bypasses the barrier decrement for it: +# the reaper has by definition already torn the barrier down, so decrementing +# would only log a spurious "decrement found no row" ERROR (false alert noise), +# and an already-terminal execution must not have its aggregating callback fired. +SKIPPED_TERMINAL_EXECUTION_KEY = "skipped_terminal_execution" + + def run_batch_with_barrier( barrier_context: BarrierContext, work_fn: Callable[[], dict[str, Any]] ) -> dict[str, Any]: @@ -1103,6 +1111,18 @@ def run_batch_with_barrier( # redelivery is blocked, and the barrier strands to expiry. try: result = work_fn() + if result.get(SKIPPED_TERMINAL_EXECUTION_KEY): + # UN-3662: the batch's execution is already terminal, so the reaper + # has by definition already torn the barrier down. Bypass the + # decrement — decrementing a gone barrier only logs a spurious + # "decrement found no row" ERROR (false alert noise) — and do NOT + # abort (nothing to tear down; and an already-terminal execution + # must not have its aggregating callback re-fired). + logger.info( + f"[exec:{execution_id}] batch {batch_index} skipped — execution " + f"already terminal; barrier decrement bypassed (UN-3662)." + ) + return result _barrier_pg_decrement( result, execution_id=execution_id, diff --git a/workers/tests/test_chord_callback_boundary.py b/workers/tests/test_chord_callback_boundary.py index 088609420a..055ff69cc4 100644 --- a/workers/tests/test_chord_callback_boundary.py +++ b/workers/tests/test_chord_callback_boundary.py @@ -776,7 +776,13 @@ class TestProcessFileBatchTransportRouting: def test_celery_path_runs_stages_directly(self, monkeypatch): from file_processing import tasks as tasks_mod - monkeypatch.setattr(tasks_mod, "_run_batch_stages", lambda *a: {"r": "celery"}) + stages_kwargs = {} + + def fake_stages(*a, **k): + stages_kwargs.update(k) + return {"r": "celery"} + + monkeypatch.setattr(tasks_mod, "_run_batch_stages", fake_stages) barrier_calls = [] monkeypatch.setattr( tasks_mod, @@ -786,11 +792,18 @@ def test_celery_path_runs_stages_directly(self, monkeypatch): out = tasks_mod._process_file_batch_core(MagicMock(), {"fb": 1}, None) assert out == {"r": "celery"} assert barrier_calls == [] # barrier path NOT taken on the celery transport + assert stages_kwargs.get("is_pg") is False # UN-3662 guard OFF on Celery def test_pg_path_routes_through_barrier(self, monkeypatch): from file_processing import tasks as tasks_mod - monkeypatch.setattr(tasks_mod, "_run_batch_stages", lambda *a: {"r": "work"}) + stages_kwargs = {} + + def fake_stages(*a, **k): + stages_kwargs.update(k) + return {"r": "work"} + + monkeypatch.setattr(tasks_mod, "_run_batch_stages", fake_stages) captured = {} def fake_run_batch_with_barrier(ctx, work_fn): @@ -805,6 +818,7 @@ def fake_run_batch_with_barrier(ctx, work_fn): assert captured["ctx"] is ctx assert out["via"] == "barrier" assert out["work"] == {"r": "work"} # work_fn runs the real stages + assert stages_kwargs.get("is_pg") is True # UN-3662 guard ON on PG if __name__ == "__main__": diff --git a/workers/tests/test_terminal_execution_guard.py b/workers/tests/test_terminal_execution_guard.py new file mode 100644 index 0000000000..372b662c8d --- /dev/null +++ b/workers/tests/test_terminal_execution_guard.py @@ -0,0 +1,167 @@ +"""UN-3662: validate-first terminal guard on the file-processing batch path. + +A batch delivered for an execution that is already terminal (COMPLETED/ERROR/ +STOPPED) must be skipped, not reprocessed — otherwise a stale/redelivered batch +arriving after the reaper recovered the execution resurrects it to EXECUTING and +re-runs its files (double LLM / destination write). These tests pin: + * the guard fires for terminal statuses and passes for active/unknown ones, + * a terminal batch is skipped WITHOUT pre-create / processing, + * the skip result is a benign zero-work batch result. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from unstract.core.data_models import ExecutionStatus + +from queue_backend.pg_barrier import SKIPPED_TERMINAL_EXECUTION_KEY + +from file_processing.tasks import ( + _raise_if_execution_terminal, + _run_batch_stages, + _terminal_skip_result, + _TerminalExecutionSkip, +) + +TERMINAL = [ + ExecutionStatus.COMPLETED.value, + ExecutionStatus.ERROR.value, + ExecutionStatus.STOPPED.value, +] +ACTIVE = [ExecutionStatus.PENDING.value, ExecutionStatus.EXECUTING.value] + + +@pytest.mark.parametrize("status", TERMINAL) +def test_guard_raises_for_terminal_on_pg(status): + with pytest.raises(_TerminalExecutionSkip) as exc: + _raise_if_execution_terminal({"status": status}, "exec-1", is_pg=True) + assert exc.value.execution_id == "exec-1" + assert exc.value.status == status + + +@pytest.mark.parametrize("status", [*ACTIVE, None, ""]) +def test_guard_passes_for_active_or_unknown_on_pg(status): + # Must NOT raise for PENDING/EXECUTING (or a missing status → fail open). + _raise_if_execution_terminal({"status": status}, "exec-1", is_pg=True) + + +@pytest.mark.parametrize("status", TERMINAL) +def test_guard_is_noop_on_celery_path(status): + # is_pg=False (Celery) → NEVER raises, even for terminal statuses. This is + # what keeps the Celery flow byte-for-byte unchanged (UN-3662). + _raise_if_execution_terminal({"status": status}, "exec-1", is_pg=False) + + +def _fake_batch_data(n_files: int = 3, org: str = "org-1"): + """A FileBatchData stand-in: real list for len(), mock file_data for org.""" + file_data = mock.Mock() + file_data.organization_id = org + return mock.Mock(files=[mock.Mock() for _ in range(n_files)], file_data=file_data) + + +def test_terminal_skip_result_counts_and_marker(): + result = _terminal_skip_result(_fake_batch_data(n_files=4, org="org-9")) + assert result["total_files"] == 4 + assert result["successful_files"] == 0 + # Unaccounted files count as failed (68d137a8), not in-progress → in-progress + # (total - successful - failed) is 0, and they don't silently vanish. + assert result["failed_files"] == 4 + # Marker tells run_batch_with_barrier to bypass the (already-gone) decrement. + assert result[SKIPPED_TERMINAL_EXECUTION_KEY] is True + + +def test_run_batch_stages_skips_terminal_without_processing(): + """Terminal execution → skip result (failed=N + marker), NO pre-create / processing.""" + bd = _fake_batch_data(n_files=2, org="org-2") + with ( + mock.patch( + "file_processing.tasks._validate_and_parse_batch_data", return_value=bd + ), + mock.patch( + "file_processing.tasks._setup_execution_context", + side_effect=_TerminalExecutionSkip("exec-2", "ERROR"), + ), + mock.patch( + "file_processing.tasks._refactored_pre_create_file_executions" + ) as pre_create, + mock.patch( + "file_processing.tasks._process_individual_files" + ) as process_files, + ): + result = _run_batch_stages({"any": "payload"}, "task-1") + + pre_create.assert_not_called() + process_files.assert_not_called() + assert result["total_files"] == 2 + assert result["successful_files"] == 0 + assert result["failed_files"] == 2 + assert result[SKIPPED_TERMINAL_EXECUTION_KEY] is True + + +def test_barrier_bypasses_decrement_on_terminal_skip(): + """UN-3662: a terminal-skip result (marker set) must NOT decrement the barrier + (the reaper already tore it down → a decrement would log a spurious ERROR) and + must NOT abort. Directly guards the Comment-2 fix in run_batch_with_barrier.""" + from queue_backend import pg_barrier + + skip_result = {"failed_files": 2, SKIPPED_TERMINAL_EXECUTION_KEY: True} + ctx = {"execution_id": "e", "batch_index": 0, "callback_descriptor": {}} + with ( + mock.patch.object(pg_barrier, "claim_batch", return_value=True), + mock.patch.object(pg_barrier, "_barrier_pg_decrement") as decrement, + mock.patch.object(pg_barrier, "_abort_barrier_in_body") as abort, + ): + out = pg_barrier.run_batch_with_barrier(ctx, lambda: skip_result) + + decrement.assert_not_called() + abort.assert_not_called() + assert out is skip_result + + +def test_barrier_decrements_on_normal_result(): + """A normal (non-skip) result still decrements the barrier — the bypass is + scoped strictly to the terminal-skip marker.""" + from queue_backend import pg_barrier + + normal = {"total_files": 1, "successful_files": 1, "failed_files": 0} + ctx = {"execution_id": "e", "batch_index": 0, "callback_descriptor": {}} + with ( + mock.patch.object(pg_barrier, "claim_batch", return_value=True), + mock.patch.object(pg_barrier, "_barrier_pg_decrement") as decrement, + ): + out = pg_barrier.run_batch_with_barrier(ctx, lambda: normal) + + decrement.assert_called_once() + assert out is normal + + +def test_run_batch_stages_proceeds_when_not_terminal(): + """Non-terminal execution → normal flow (setup → pre-create → process).""" + bd = _fake_batch_data(n_files=1) + with ( + mock.patch( + "file_processing.tasks._validate_and_parse_batch_data", return_value=bd + ), + mock.patch( + "file_processing.tasks._setup_execution_context", + return_value="ctx", + ), + mock.patch( + "file_processing.tasks._refactored_pre_create_file_executions", + return_value="ctx", + ) as pre_create, + mock.patch( + "file_processing.tasks._process_individual_files", return_value="ctx" + ) as process_files, + mock.patch( + "file_processing.tasks._compile_batch_result", + return_value={"total_files": 1, "successful_files": 1, "failed_files": 0}, + ), + ): + result = _run_batch_stages({"any": "payload"}, "task-1") + + pre_create.assert_called_once() + process_files.assert_called_once() + assert result["successful_files"] == 1 From ccbe59aba55390dd11f20cdc8ddd4c32801aeb80 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:00:21 +0530 Subject: [PATCH 52/65] =?UTF-8?q?UN-3663=20[FIX]=20PG=20queue=20=E2=80=94?= =?UTF-8?q?=20skip=20duplicate=20destination=20write=20on=20re-run=20(Gap?= =?UTF-8?q?=20A)=20+=20UN-3662=20review=20nits=20(#2138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3662 [FIX] address review nits — keyword-only is_pg, self-contained comments, guard-placement test Follow-up to the merged terminal-guard change. No behavior change; polish only: - is_pg is now keyword-only with no default on _run_batch_stages / _setup_execution_context, so a PG call site can't silently disable the guard. - Fail-open on a missing execution status now emits a warning (was silently masked) — a missing status signals a degraded fetch, not an active run. - Docstrings updated (Args/Raises for is_pg + the skip exception); comment rationale de-duplicated to the class docstring; wording corrected ('behaviorally unchanged', accurate 'raised by', reaper-is-PG-only reason); documented the residual read-then-act window (guard narrows, not CAS). - Stripped internal tracker/commit references from code comments (public repo). - New test drives _setup_execution_context with a mocked API client returning a terminal status, asserting it raises AND the EXECUTING write never happens (covers the is_pg forwarding + guard placement) + a fail-open-warning test. Co-Authored-By: Claude Opus 4.8 * UN-3663 [FIX] PG queue — skip a duplicate destination write on re-run (Gap A); align active-file TTL cap Exactly-once hardening for the PG (at-least-once) execution path. A batch can be re-run after a crash / reaper-recovery, and such a re-run bypasses discovery's FileHistory filter — so if the file's destination write already completed in a prior run, the write is repeated, producing a duplicate. - Destination re-check (PG only): before destination.handle_output(), re-check FileHistory by hash; if a COMPLETED record exists, skip the write and reuse the existing duplicate-skip path. Gated on is_pg_transport(file_data.transport) so the Celery destination path is byte-unchanged, and fail-open so any lookup error never blocks a legitimate write. No new DB migration. - Active-file TTL cap raised 7200 -> 9000 (>= the PG stuck-batch reaper timeout). Default (300s) unchanged; only the allowed maximum widens, so this can't regress either transport. Deferred (own follow-up, cannot be cleanly PG-gated): atomic backend FileHistory-create + file-status write (shared endpoint); a post-reaper reconciliation sweep for the write-then-crash-before-record window. Co-Authored-By: Claude Opus 4.8 * UN-3663 [FIX] address review — thread real transport to the destination guard (was dead code) + hardening Critical: the PG duplicate-write guard read file_data.transport, but WorkerFileData carries no transport field, so getattr(..., None) was always None and the guard never fired (dead code; the spec-less test mock hid it). - Thread the authoritative transport from the batch (is_pg) onto WorkflowContextData, through _process_file -> FileProcessor.process_file -> FileProcessingContext, and read it in _handle_destination_processing; the guard now takes a typed transport param (no getattr default). - Honour use_file_history: an ETL/TASK run with file history off is contractually 'rewrite every run' -> never skip its write. - Fail-open logs at error (Sentry-visible) so a persistent lookup regression isn't a silent permanent no-op. - Terminal guard also warns on an unrecognized (non-empty) status. - active-file TTL: warn on clamp/parse-fail; fix stale '2 hours' text. - Duplicate-skip returns a distinct processed=False result; strip 'Gap A' tag; type the guard params. Tests: guard via real transport param + spec=FileHashData; use_file_history=False case; real-call-site wiring test; TTL clamp-warning test. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- workers/file_processing/tasks.py | 107 +++++++--- workers/queue_backend/pg_barrier.py | 24 +-- workers/shared/models/file_processing.py | 5 + workers/shared/processing/files/processor.py | 2 + .../workflow/execution/active_file_manager.py | 34 ++- workers/shared/workflow/execution/service.py | 81 ++++++++ .../tests/test_pg_duplicate_write_guard.py | 195 ++++++++++++++++++ .../tests/test_terminal_execution_guard.py | 63 ++++-- 8 files changed, 446 insertions(+), 65 deletions(-) create mode 100644 workers/tests/test_pg_duplicate_write_guard.py diff --git a/workers/file_processing/tasks.py b/workers/file_processing/tasks.py index f37c924de9..05b71ffaa4 100644 --- a/workers/file_processing/tasks.py +++ b/workers/file_processing/tasks.py @@ -54,6 +54,7 @@ FileHashData, PreCreatedFileData, WorkerFileData, + WorkflowTransport, ) from unstract.core.worker_models import ( ApiDeploymentResultStatus, @@ -230,16 +231,23 @@ def _enhance_batch_with_mrq_flags( class _TerminalExecutionSkip(Exception): """A batch was delivered for an execution already in a terminal state. - Raised by :func:`_setup_execution_context` when the execution's status is + Raised by :func:`_raise_if_execution_terminal` (called from + :func:`_setup_execution_context`) when the execution's status is COMPLETED/ERROR/STOPPED. On the PG queue (at-least-once), a batch can be redelivered — or a stale batch consumed — *after* the reaper has recovered the execution (marked it ERROR) and deleted its barrier. Reprocessing would resurrect the execution back to EXECUTING and re-run its files (double LLM / - destination write). The batch is skipped instead (UN-3662). + destination write). The batch is skipped instead. - The guard is gated to the PG path (``is_pg``) so the Celery flow is - byte-for-byte unchanged — the resurrection it prevents is PG-specific - (Celery lost tasks are never redelivered, so they can't be resurrected). + Gated to the PG path (``is_pg``): a no-op on Celery, so the existing Celery + flow is behaviorally unchanged. It's PG-specific because the **reaper** that + marks an in-flight execution terminal (and tears its barrier down) is a + PG-only mechanism — the Celery chord has no equivalent. + + This is a validate-first *narrowing*, not a transactional guarantee: the + status read and the later ``EXECUTING`` write are not atomic, so a reaper + transition landing in that window can still resurrect the execution. The + reaper's terminal file-cascade is the backstop for that residual case. """ def __init__(self, execution_id: str, status: str | None) -> None: @@ -258,25 +266,45 @@ def _raise_if_execution_terminal( execution is already terminal. See :class:`_TerminalExecutionSkip`. No-op on the Celery path (``is_pg=False``) — the resurrection this prevents - is PG-specific, so gating keeps the Celery flow byte-for-byte unchanged - (UN-3662). A missing/unknown status is treated as non-terminal (fail open — - proceed as normal). + is PG-specific, so gating leaves the Celery flow behaviorally unchanged. A + missing or unrecognized status is treated as non-terminal (fail open — + proceed as normal), but both are logged on the PG path so a degraded + execution-fetch response or a new/renamed status isn't silently masked (an + unrecognized terminal-ish state would otherwise let a stale batch through). """ if not is_pg: return status = workflow_execution.get("status") + if not status: + # Fail open (proceed), but surface it — a missing status is an + # API-contract regression, not a normal active execution. + logger.warning( + f"[exec:{execution_id}] terminal guard: execution-fetch returned no " + f"status; proceeding (fail-open) but the response looks degraded." + ) + return if status in ExecutionStatus.terminal_values(): raise _TerminalExecutionSkip(execution_id, status) + if status not in {s.value for s in ExecutionStatus}: + # Recognized non-terminal (PENDING/EXECUTING) proceeds silently; an + # UNRECOGNIZED value is surfaced so a new terminal-ish state or an enum + # typo can't silently defeat the guard. + logger.warning( + f"[exec:{execution_id}] terminal guard: unrecognized execution " + f"status {status!r}; treating as non-terminal (proceeding)." + ) def _terminal_skip_result(batch_data: FileBatchData) -> dict[str, Any]: - """Batch result for a batch skipped because its execution is already terminal - (UN-3662). The files were never attempted; per the accounting contract from - 68d137a8 (unaccounted files count as failed, not in-progress) they are - reported as failed, so a consumer that ever aggregates this result does not - render them as perpetually in-progress. The ``SKIPPED_TERMINAL_EXECUTION_KEY`` - marker tells ``run_batch_with_barrier`` to bypass the barrier decrement (the - reaper has by definition already torn the barrier down). + """Batch result for a batch skipped because its execution is already terminal. + + The files were never attempted; they are counted as failed (not left + unaccounted). The UI derives in-progress as ``total - successful - failed``, + so reporting ``failed=total`` keeps a consumer that ever aggregates this + result from rendering the files as perpetually in-progress. The + ``SKIPPED_TERMINAL_EXECUTION_KEY`` marker tells ``run_batch_with_barrier`` to + bypass the barrier decrement (a terminal execution's barrier is by definition + already torn down). """ n_files = len(batch_data.files) result = BatchExecutionResult( @@ -291,7 +319,7 @@ def _terminal_skip_result(batch_data: FileBatchData) -> dict[str, Any]: def _run_batch_stages( - file_batch_data: dict[str, Any], celery_task_id: str, is_pg: bool = False + file_batch_data: dict[str, Any], celery_task_id: str, *, is_pg: bool ) -> dict[str, Any]: """The actual batch work (validate → setup → pre-create → process → compile). @@ -299,15 +327,16 @@ def _run_batch_stages( fire-and-forget path. The task instance isn't needed here — its only use (deriving ``celery_task_id``) happens in the caller. Returns the JSON-serialisable batch result. + + ``is_pg`` (keyword-only, no default so a PG call site can't silently forget + it) forwards to the terminal guard; see :class:`_TerminalExecutionSkip`. """ # Step 1: Validate and parse input data batch_data = _validate_and_parse_batch_data(file_batch_data) - # Step 2: Setup execution context. Validate-first (PG path only): a batch - # delivered for an already-terminal execution is a stale/redelivered task - # arriving after the reaper recovered the execution — skip it rather than - # resurrect the execution and re-run its files (UN-3662). The guard is gated - # inside _setup_execution_context via is_pg, so on Celery this never fires. + # Step 2: Setup execution context. The terminal guard inside it may skip a + # stale/redelivered batch for an already-terminal execution — see + # :class:`_TerminalExecutionSkip`. try: context = _setup_execution_context(batch_data, celery_task_id, is_pg=is_pg) except _TerminalExecutionSkip as skip: @@ -315,7 +344,7 @@ def _run_batch_stages( f"[exec:{skip.execution_id}] Skipping batch of " f"{len(batch_data.files)} file(s): execution already terminal " f"({skip.status}) — stale/redelivered after reaper-recovery; not " - f"reprocessing (UN-3662)." + f"reprocessing." ) return _terminal_skip_result(batch_data) @@ -360,13 +389,13 @@ def _process_file_batch_core( if barrier_context is None: # Celery chord path — the chord's .link runs the decrement after this. - # is_pg=False disables the UN-3662 terminal guard → Celery flow unchanged. + # is_pg=False disables the terminal guard → Celery flow unchanged. return _run_batch_stages(file_batch_data, celery_task_id, is_pg=False) # PG fire-and-forget path — claim the batch (idempotent on redelivery), run # the stages, then decrement the barrier in-body / self-chain the callback. - # is_pg=True enables the UN-3662 terminal guard (skip stale/redelivered - # batches for a reaper-recovered execution). + # is_pg=True enables the terminal guard (skip a stale/redelivered batch for a + # reaper-recovered execution). return run_batch_with_barrier( barrier_context, lambda: _run_batch_stages(file_batch_data, celery_task_id, is_pg=True), @@ -441,19 +470,24 @@ def _validate_and_parse_batch_data(file_batch_data: dict[str, Any]) -> FileBatch def _setup_execution_context( - batch_data: FileBatchData, celery_task_id: str, is_pg: bool = False + batch_data: FileBatchData, celery_task_id: str, *, is_pg: bool ) -> WorkflowContextData: """Setup execution context with validation and API client initialization. Args: batch_data: Validated batch data celery_task_id: Celery task ID for tracking + is_pg: Whether this batch runs on the PG (at-least-once) transport. + Keyword-only, no default so a PG call site can't silently forget it. + Enables the terminal guard; a no-op on Celery. Returns: WorkflowContextData containing type-safe execution context Raises: ValueError: If required context fields are missing + _TerminalExecutionSkip: On the PG path, if the execution is already + terminal (a stale/redelivered batch after reaper-recovery). """ # Extract context using dataclass file_data = batch_data.file_data @@ -493,12 +527,9 @@ def _setup_execution_context( execution_context = execution_response.data workflow_execution = execution_context.get("execution", {}) - # UN-3662: validate-first terminal guard (PG path only, gated by is_pg). If - # the execution is already terminal (COMPLETED/ERROR/STOPPED), this batch is - # a stale/redelivered task arriving after the reaper recovered the execution - # — reprocessing would resurrect it to EXECUTING and re-run its files. Skip - # before we touch status or process anything. On Celery (is_pg=False) this is - # a no-op, so the existing Celery flow is unchanged. + # Validate-first terminal guard (PG only) — skip a stale/redelivered batch + # for an already-terminal execution, before we touch status or process + # anything. See :class:`_TerminalExecutionSkip`. _raise_if_execution_terminal(workflow_execution, execution_id, is_pg=is_pg) # Set LOG_EVENTS_ID in StateStore for WebSocket messaging (critical for UI logs) @@ -568,13 +599,18 @@ def _setup_execution_context( f"File history from fallback access for workflow {workflow_id}: use_file_history = {use_file_history}" ) - # Create type-safe workflow context + # Create type-safe workflow context. Set the transport authoritatively from + # is_pg (the batch-level fact) — WorkerFileData carries none — so the + # destination layer can apply PG-only guards. context_data = WorkflowContextData( workflow_id=workflow_id, workflow_name=workflow_name, workflow_type=workflow_type, execution_id=execution_id, organization_context=org_context, + transport=( + WorkflowTransport.PG_QUEUE.value if is_pg else WorkflowTransport.CELERY.value + ), files={ f"file_{i}": file for i, file in enumerate(files) }, # Convert list to dict format @@ -839,6 +875,7 @@ def _process_individual_files(context: WorkflowContextData) -> WorkflowContextDa workflow_file_execution_id=workflow_file_execution_id, # Pass pre-created ID workflow_file_execution_object=workflow_file_execution_object, # Pass pre-created object workflow_logger=workflow_logger, # Pass workflow logger for UI logging + transport=context.transport, # Drives the PG-only destination guard ) # Handle file processing result @@ -1608,6 +1645,7 @@ def _process_file( workflow_file_execution_id: str = None, workflow_file_execution_object: Any = None, workflow_logger: Any = None, + transport: str | None = None, ) -> dict[str, Any]: """Process a single file matching Django backend _process_file pattern. @@ -1621,6 +1659,8 @@ def _process_file( file_hash: FileHashData instance with type-safe access api_client: Internal API client workflow_execution: Workflow execution context + transport: Execution transport (celery | pg_queue); drives PG-only + destination guards downstream. Returns: File execution result @@ -1636,6 +1676,7 @@ def _process_file( workflow_file_execution_id=workflow_file_execution_id, workflow_file_execution_object=workflow_file_execution_object, workflow_logger=workflow_logger, + transport=transport, ) diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 0180585271..531cc7d28c 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -1037,11 +1037,11 @@ def _abort_barrier_in_body(execution_id: str, *, reason: str) -> None: ) -# A batch whose execution is already terminal (UN-3662) returns its result with -# this marker set. run_batch_with_barrier bypasses the barrier decrement for it: -# the reaper has by definition already torn the barrier down, so decrementing -# would only log a spurious "decrement found no row" ERROR (false alert noise), -# and an already-terminal execution must not have its aggregating callback fired. +# A batch whose execution is already terminal returns its result with this +# marker set. run_batch_with_barrier bypasses the barrier decrement for it: the +# reaper has by definition already torn the barrier down, so decrementing would +# only log a spurious "decrement found no row" ERROR (false alert noise), and an +# already-terminal execution must not have its aggregating callback fired. SKIPPED_TERMINAL_EXECUTION_KEY = "skipped_terminal_execution" @@ -1112,15 +1112,15 @@ def run_batch_with_barrier( try: result = work_fn() if result.get(SKIPPED_TERMINAL_EXECUTION_KEY): - # UN-3662: the batch's execution is already terminal, so the reaper - # has by definition already torn the barrier down. Bypass the - # decrement — decrementing a gone barrier only logs a spurious - # "decrement found no row" ERROR (false alert noise) — and do NOT - # abort (nothing to tear down; and an already-terminal execution - # must not have its aggregating callback re-fired). + # The batch's execution is already terminal, so the reaper has by + # definition already torn the barrier down. Bypass the decrement — + # decrementing a gone barrier only logs a spurious "decrement found + # no row" ERROR (false alert noise) — and do NOT abort (nothing to + # tear down; and an already-terminal execution must not have its + # aggregating callback re-fired). logger.info( f"[exec:{execution_id}] batch {batch_index} skipped — execution " - f"already terminal; barrier decrement bypassed (UN-3662)." + f"already terminal; barrier decrement bypassed." ) return result _barrier_pg_decrement( diff --git a/workers/shared/models/file_processing.py b/workers/shared/models/file_processing.py index af40624176..b634d795eb 100644 --- a/workers/shared/models/file_processing.py +++ b/workers/shared/models/file_processing.py @@ -32,6 +32,7 @@ def __init__( workflow_logger: Any = None, # Type as Any to avoid import dependency current_file_idx: int = 1, total_files: int = 1, + transport: str | None = None, ): self.file_data = file_data self.file_hash = file_hash @@ -42,6 +43,10 @@ def __init__( self.workflow_logger = workflow_logger self.current_file_idx = current_file_idx self.total_files = total_files + # Execution transport (celery | pg_queue). Authoritative here because + # WorkerFileData carries no transport; threaded from the batch context so + # the destination layer can apply PG-only guards. + self.transport = transport # Extract common identifiers self.execution_id = file_data.execution_id diff --git a/workers/shared/processing/files/processor.py b/workers/shared/processing/files/processor.py index 90009d3a55..52c4b9a11f 100644 --- a/workers/shared/processing/files/processor.py +++ b/workers/shared/processing/files/processor.py @@ -477,6 +477,7 @@ def process_file( workflow_file_execution_id: str = None, workflow_file_execution_object: Any = None, workflow_logger: WorkerWorkflowLogger = None, + transport: str | None = None, ) -> FileProcessingResult: """Main orchestrator method that replaces the complex _process_file method. @@ -512,6 +513,7 @@ def process_file( workflow_logger=workflow_logger, current_file_idx=current_file_idx, total_files=total_files, + transport=transport, ) logger.debug( diff --git a/workers/shared/workflow/execution/active_file_manager.py b/workers/shared/workflow/execution/active_file_manager.py index 1814a91106..be056b40aa 100644 --- a/workers/shared/workflow/execution/active_file_manager.py +++ b/workers/shared/workflow/execution/active_file_manager.py @@ -20,23 +20,43 @@ # Constants for cache configuration DEFAULT_ACTIVE_FILE_CACHE_TTL = 300 # 5 minutes -MAX_ACTIVE_FILE_CACHE_TTL = 7200 # 2 hours maximum +# Cap the active-file marker TTL at the PG stuck-batch reaper's DEFAULT timeout +# (WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS, 9000s) so a marker can be configured to +# outlive a stalled-but-not-yet-reaped batch — below it there is a window where +# the marker has expired but the reaper hasn't yet marked the execution terminal. +# NOTE: the reaper timeout is env-tunable; if it's raised above 9000, raise this +# too or that window reopens. Default marker TTL stays 300s; operators opt in to +# longer. +MAX_ACTIVE_FILE_CACHE_TTL = 9000 # 2.5 hours maximum def get_active_file_cache_ttl() -> int: """Get the configurable TTL for active file cache entries. Returns: - TTL in seconds, with sensible defaults and bounds checking + TTL in seconds, clamped to [60, MAX_ACTIVE_FILE_CACHE_TTL] (1 minute to + 2.5 hours). A non-integer or out-of-range ``ACTIVE_FILE_CACHE_TTL`` is + surfaced as a warning rather than silently swallowed — a silently + shortened TTL re-opens the very window a raised value was meant to close. """ + raw = os.environ.get("ACTIVE_FILE_CACHE_TTL") + if raw is None: + return DEFAULT_ACTIVE_FILE_CACHE_TTL try: - ttl = int(os.environ.get("ACTIVE_FILE_CACHE_TTL", DEFAULT_ACTIVE_FILE_CACHE_TTL)) - # Ensure TTL is within reasonable bounds - return min( - max(ttl, 60), MAX_ACTIVE_FILE_CACHE_TTL - ) # Between 1 minute and 2 hours + ttl = int(raw) except (ValueError, TypeError): + logger.warning( + f"ACTIVE_FILE_CACHE_TTL={raw!r} is not an integer; " + f"using default {DEFAULT_ACTIVE_FILE_CACHE_TTL}s." + ) return DEFAULT_ACTIVE_FILE_CACHE_TTL + clamped = min(max(ttl, 60), MAX_ACTIVE_FILE_CACHE_TTL) + if clamped != ttl: + logger.warning( + f"ACTIVE_FILE_CACHE_TTL={ttl}s out of range; clamped to {clamped}s " + f"(allowed 60..{MAX_ACTIVE_FILE_CACHE_TTL})." + ) + return clamped class LoggerProtocol(Protocol): diff --git a/workers/shared/workflow/execution/service.py b/workers/shared/workflow/execution/service.py index 80c3b85148..0e076ebfc4 100644 --- a/workers/shared/workflow/execution/service.py +++ b/workers/shared/workflow/execution/service.py @@ -25,6 +25,7 @@ FileOperationConstants, WorkflowDefinitionResponseData, WorkflowEndpointConfigData, + is_pg_transport, ) # Import file execution tracking for proper recovery mechanism @@ -1361,6 +1362,61 @@ def _resolve_connector_config( logger.error("No connector_id found in any configuration source") return None, {} + def _pg_destination_already_written( + self, + *, + file_hash: FileHashData, + transport: str | None, + use_file_history: bool, + workflow_id: str, + is_api: bool, + ) -> bool: + """True if this file's destination write already completed in a prior run. + + On the PG (at-least-once) transport a batch can be re-run after a crash + or reaper-recovery, and such a re-run bypasses discovery's FileHistory + filter — so re-check by hash (plus path for non-API) at the write + boundary. Returns True only if a COMPLETED FileHistory record already + exists for this file. + + Scoped two ways: + - to the PG transport (the re-run duplicate this prevents is PG-specific; + always False on Celery, so that path is unchanged), and + - to ``use_file_history``: a workflow run with file history off is + contractually "rewrite every run" (mirrors the discovery FileHistory + filter), so its write is never skipped. + + Fail-open on any lookup error (never block a legitimate write), logging + at error level so a persistent lookup regression is visible rather than a + silent, permanent no-op. + """ + if not is_pg_transport(transport): + return False + if not use_file_history: + return False + cache_key = file_hash.file_hash + if not cache_key: + return False + # API executions use unique per-execution paths, so match on hash only. + lookup_file_path = None if is_api else file_hash.file_path + try: + history_result = self.api_client.get_file_history_by_cache_key( + cache_key=cache_key, + workflow_id=workflow_id, + file_path=lookup_file_path, + ) + except Exception: + logger.error( + f"PG duplicate-write guard: FileHistory lookup failed for " + f"'{file_hash.file_name}'; proceeding with the write (fail-open).", + exc_info=True, + ) + return False + if not history_result or not history_result.get("found"): + return False + file_history = history_result.get("file_history") or {} + return file_history.get("status") == ExecutionStatus.COMPLETED.value + def _handle_destination_processing( self, file_processing_context: FileProcessingContext, @@ -1471,6 +1527,31 @@ def _handle_destination_processing( f"📤 File {file_hash.file_name} marked for DESTINATION processing - sending to {destination_display}" ) + # PG-only re-run duplicate-write guard: a re-run after a crash / + # reaper-recovery bypasses discovery's FileHistory filter, so + # re-check by hash at the write boundary. If this file's + # destination write already completed in a prior run, skip it, + # avoiding a duplicate destination write. A no-op on the Celery + # transport, so that path is unchanged; fail-open so a lookup + # error never blocks a write. + if self._pg_destination_already_written( + file_hash=file_hash, + transport=file_processing_context.transport, + use_file_history=use_file_history, + workflow_id=workflow_id, + is_api=destination.is_api, + ): + logger.info( + f"[exec:{execution_id}] Skipping destination write for " + f"'{file_hash.file_name}' — already completed in a prior " + f"run (FileHistory hit); avoiding a duplicate write." + ) + # Distinct from the handle_output()->None "internal race" skip + # below: this is a deliberate re-run dedup, not a race. + return FinalOutputResult( + output=None, metadata=None, error=None, processed=False + ) + # Process final output through destination (exact backend signature + workers-specific params) handle_output_result = destination.handle_output( is_success=is_success, diff --git a/workers/tests/test_pg_duplicate_write_guard.py b/workers/tests/test_pg_duplicate_write_guard.py new file mode 100644 index 0000000000..a91c62553a --- /dev/null +++ b/workers/tests/test_pg_duplicate_write_guard.py @@ -0,0 +1,195 @@ +"""PG-only duplicate-destination-write guard + active-file TTL cap. + +On the PG (at-least-once) transport a batch can be re-run after a crash / +reaper-recovery, and such a re-run bypasses discovery's FileHistory filter. The +destination path therefore re-checks FileHistory by hash right before the write +and skips it when the file already completed in a prior run — closing the +duplicate-write window. The guard is a no-op on the Celery transport (that path +is unchanged), honours use_file_history (ETL "rewrite every run"), and is +fail-open on any lookup error (never blocks a real write). +""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from unstract.core.data_models import ( + ExecutionStatus, + FileHashData, + WorkflowTransport, +) + +from shared.models.file_processing import FileProcessingContext +from shared.workflow.execution.active_file_manager import ( + MAX_ACTIVE_FILE_CACHE_TTL, + get_active_file_cache_ttl, +) +from shared.workflow.execution.service import WorkerWorkflowExecutionService + +PG = WorkflowTransport.PG_QUEUE.value +CELERY = WorkflowTransport.CELERY.value + + +def _svc(api_client=None): + # Bypass __init__ — we only exercise the guard + destination wiring. + svc = WorkerWorkflowExecutionService.__new__(WorkerWorkflowExecutionService) + svc.api_client = api_client or mock.Mock() + svc.logger = mock.Mock() + svc._last_execution_error = None + return svc + + +def _file_hash(h="hash-1"): + # spec=FileHashData so a typo'd/renamed attr fails loudly instead of being + # fabricated (the spec-less mock is what hid the original dead-code bug). + return mock.Mock( + spec=FileHashData, file_hash=h, file_path="/root/f.pdf", file_name="f.pdf" + ) + + +def _history(found=True, status=ExecutionStatus.COMPLETED.value): + return {"found": found, "file_history": {"status": status} if found else None} + + +def _call(svc, *, transport, use_file_history=True, is_api=False, file_hash=None): + return svc._pg_destination_already_written( + file_hash=file_hash or _file_hash(), + transport=transport, + use_file_history=use_file_history, + workflow_id="wf-1", + is_api=is_api, + ) + + +def test_celery_transport_never_looks_up_or_skips(): + svc = _svc() + assert _call(svc, transport=CELERY) is False + svc.api_client.get_file_history_by_cache_key.assert_not_called() + + +def test_use_file_history_false_never_looks_up_or_skips(): + # ETL/TASK "rewrite every run" contract — never skip its write. + svc = _svc() + assert _call(svc, transport=PG, use_file_history=False) is False + svc.api_client.get_file_history_by_cache_key.assert_not_called() + + +def test_pg_skips_when_completed_history_exists(): + svc = _svc() + svc.api_client.get_file_history_by_cache_key.return_value = _history() + assert _call(svc, transport=PG) is True + + +@pytest.mark.parametrize( + "history", + [ + {"found": False, "file_history": None}, + {"found": True, "file_history": {"status": ExecutionStatus.ERROR.value}}, + {"found": True, "file_history": {}}, + ], +) +def test_pg_proceeds_when_not_completed(history): + svc = _svc() + svc.api_client.get_file_history_by_cache_key.return_value = history + assert _call(svc, transport=PG) is False + + +def test_pg_fails_open_and_logs_on_lookup_error(): + svc = _svc() + svc.api_client.get_file_history_by_cache_key.side_effect = RuntimeError("api down") + with mock.patch( + "shared.workflow.execution.service.logger" + ) as log: + assert _call(svc, transport=PG) is False # fail-open + log.error.assert_called_once() # loud (Sentry-visible), not a silent warning + + +def test_pg_proceeds_when_no_cache_key(): + svc = _svc() + assert _call(svc, transport=PG, file_hash=_file_hash(h=None)) is False + svc.api_client.get_file_history_by_cache_key.assert_not_called() + + +def test_api_execution_matches_on_hash_only(): + svc = _svc() + svc.api_client.get_file_history_by_cache_key.return_value = _history() + _call(svc, transport=PG, is_api=True) + _, kwargs = svc.api_client.get_file_history_by_cache_key.call_args + assert kwargs["file_path"] is None + + +def test_file_processing_context_carries_transport(): + ctx = FileProcessingContext( + file_data=mock.Mock(), + file_hash=_file_hash(), + api_client=mock.Mock(), + workflow_execution={}, + transport=PG, + ) + assert ctx.transport == PG + + +def test_handle_destination_processing_forwards_transport_and_skips_on_hit(): + """Real-call-site wiring: `_handle_destination_processing` forwards the + context's transport (not a non-existent `file_data.transport`) to the guard, + and on a hit it must NOT call `destination.handle_output`. This is exactly + the regression the original dead `getattr(file_data,'transport')` introduced. + """ + svc = _svc() + file_hash = _file_hash() + file_hash.is_manualreview_required = False + ctx = FileProcessingContext( + file_data=mock.Mock(), + file_hash=file_hash, + api_client=svc.api_client, + workflow_execution={}, + transport=PG, + ) + workflow = mock.Mock() + workflow.destination_config.to_dict.return_value = {} + destination = mock.Mock(is_api=False) + + with ( + mock.patch("shared.workflow.execution.service.DestinationConfig"), + mock.patch( + "shared.workflow.execution.service.WorkerDestinationConnector" + ) as WDC, + mock.patch.object( + svc, "_pg_destination_already_written", return_value=True + ) as guard, + ): + WDC.from_config.return_value = destination + result = svc._handle_destination_processing( + file_processing_context=ctx, + workflow=workflow, + workflow_id="wf", + execution_id="e", + is_success=True, + workflow_file_execution_id="fx", + organization_id="org", + use_file_history=True, + is_api=False, + ) + + assert guard.call_args.kwargs["transport"] == PG # threaded from the context + assert guard.call_args.kwargs["use_file_history"] is True + destination.handle_output.assert_not_called() # skipped, no duplicate write + assert result.processed is False + + +def test_active_file_ttl_cap_aligned_to_reaper_window(): + assert MAX_ACTIVE_FILE_CACHE_TTL == 9000 + with mock.patch.dict("os.environ", {"ACTIVE_FILE_CACHE_TTL": "8000"}): + assert get_active_file_cache_ttl() == 8000 # was capped at 7200 before + + +def test_active_file_ttl_out_of_range_clamps_and_warns(): + with ( + mock.patch.dict("os.environ", {"ACTIVE_FILE_CACHE_TTL": "999999"}), + mock.patch( + "shared.workflow.execution.active_file_manager.logger" + ) as log, + ): + assert get_active_file_cache_ttl() == MAX_ACTIVE_FILE_CACHE_TTL + log.warning.assert_called_once() diff --git a/workers/tests/test_terminal_execution_guard.py b/workers/tests/test_terminal_execution_guard.py index 372b662c8d..bc301979bc 100644 --- a/workers/tests/test_terminal_execution_guard.py +++ b/workers/tests/test_terminal_execution_guard.py @@ -1,16 +1,19 @@ -"""UN-3662: validate-first terminal guard on the file-processing batch path. +"""Validate-first terminal guard on the file-processing batch path. A batch delivered for an execution that is already terminal (COMPLETED/ERROR/ STOPPED) must be skipped, not reprocessed — otherwise a stale/redelivered batch arriving after the reaper recovered the execution resurrects it to EXECUTING and re-runs its files (double LLM / destination write). These tests pin: - * the guard fires for terminal statuses and passes for active/unknown ones, + * the guard fires for terminal statuses (PG) and is a no-op on Celery, + * the guard runs at its real call site, before the EXECUTING status write, * a terminal batch is skipped WITHOUT pre-create / processing, - * the skip result is a benign zero-work batch result. + * the skip result counts skipped files as failed and carries the bypass marker, + * run_batch_with_barrier bypasses the barrier decrement on a marked result. """ from __future__ import annotations +import types from unittest import mock import pytest @@ -21,6 +24,7 @@ from file_processing.tasks import ( _raise_if_execution_terminal, _run_batch_stages, + _setup_execution_context, _terminal_skip_result, _TerminalExecutionSkip, ) @@ -41,19 +45,52 @@ def test_guard_raises_for_terminal_on_pg(status): assert exc.value.status == status -@pytest.mark.parametrize("status", [*ACTIVE, None, ""]) -def test_guard_passes_for_active_or_unknown_on_pg(status): - # Must NOT raise for PENDING/EXECUTING (or a missing status → fail open). +@pytest.mark.parametrize("status", ACTIVE) +def test_guard_passes_for_active_on_pg(status): + # Must NOT raise for PENDING/EXECUTING. _raise_if_execution_terminal({"status": status}, "exec-1", is_pg=True) @pytest.mark.parametrize("status", TERMINAL) def test_guard_is_noop_on_celery_path(status): # is_pg=False (Celery) → NEVER raises, even for terminal statuses. This is - # what keeps the Celery flow byte-for-byte unchanged (UN-3662). + # what keeps the Celery flow behaviorally unchanged. _raise_if_execution_terminal({"status": status}, "exec-1", is_pg=False) +def test_missing_status_on_pg_warns_and_proceeds(): + # A missing status is fail-open (no raise) but surfaced as a warning, since + # it signals a degraded execution-fetch response rather than an active run. + with mock.patch("file_processing.tasks.logger") as log: + _raise_if_execution_terminal({}, "exec-1", is_pg=True) + log.warning.assert_called_once() + + +def test_guard_fires_at_real_call_site_before_status_write(): + """Exercises the real wiring the mocked-setup tests skip: the is_pg forward + chain and the guard's placement in _setup_execution_context — after the + execution fetch, before the EXECUTING status write.""" + api_client = mock.Mock() + api_client.get_workflow_execution.return_value = types.SimpleNamespace( + success=True, data={"execution": {"status": ExecutionStatus.ERROR.value}} + ) + file_data = mock.Mock( + execution_id="exec-9", workflow_id="wf-1", organization_id="org-1" + ) + batch_data = mock.Mock(files=[mock.Mock()], file_data=file_data) + + with ( + mock.patch("file_processing.tasks.StateStore"), + mock.patch("file_processing.tasks.create_api_client", return_value=api_client), + mock.patch("file_processing.tasks.create_organization_context"), + pytest.raises(_TerminalExecutionSkip), + ): + _setup_execution_context(batch_data, "task-1", is_pg=True) + + # The guard fired BEFORE the EXECUTING status write. + api_client.update_workflow_execution_status.assert_not_called() + + def _fake_batch_data(n_files: int = 3, org: str = "org-1"): """A FileBatchData stand-in: real list for len(), mock file_data for org.""" file_data = mock.Mock() @@ -65,7 +102,7 @@ def test_terminal_skip_result_counts_and_marker(): result = _terminal_skip_result(_fake_batch_data(n_files=4, org="org-9")) assert result["total_files"] == 4 assert result["successful_files"] == 0 - # Unaccounted files count as failed (68d137a8), not in-progress → in-progress + # Skipped files count as failed, not left unaccounted → in-progress # (total - successful - failed) is 0, and they don't silently vanish. assert result["failed_files"] == 4 # Marker tells run_batch_with_barrier to bypass the (already-gone) decrement. @@ -90,7 +127,7 @@ def test_run_batch_stages_skips_terminal_without_processing(): "file_processing.tasks._process_individual_files" ) as process_files, ): - result = _run_batch_stages({"any": "payload"}, "task-1") + result = _run_batch_stages({"any": "payload"}, "task-1", is_pg=True) pre_create.assert_not_called() process_files.assert_not_called() @@ -101,9 +138,9 @@ def test_run_batch_stages_skips_terminal_without_processing(): def test_barrier_bypasses_decrement_on_terminal_skip(): - """UN-3662: a terminal-skip result (marker set) must NOT decrement the barrier - (the reaper already tore it down → a decrement would log a spurious ERROR) and - must NOT abort. Directly guards the Comment-2 fix in run_batch_with_barrier.""" + """A terminal-skip result (marker set) must NOT decrement the barrier (the + reaper already tore it down → a decrement would log a spurious ERROR) and + must NOT abort.""" from queue_backend import pg_barrier skip_result = {"failed_files": 2, SKIPPED_TERMINAL_EXECUTION_KEY: True} @@ -160,7 +197,7 @@ def test_run_batch_stages_proceeds_when_not_terminal(): return_value={"total_files": 1, "successful_files": 1, "failed_files": 0}, ), ): - result = _run_batch_stages({"any": "payload"}, "task-1") + result = _run_batch_stages({"any": "payload"}, "task-1", is_pg=False) pre_create.assert_called_once() process_files.assert_called_once() From 3883d5af52fd68bbbef872336b26c059248467f2 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:03:37 +0530 Subject: [PATCH 53/65] =?UTF-8?q?UN-3667=20[TEST]=20PG=20queue=20=E2=80=94?= =?UTF-8?q?=20make=20workers=20suite=20deterministic=20+=20wire=20into=20C?= =?UTF-8?q?I=20(UN-3666)=20(#2139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3667 [TEST] PG queue — make workers suite deterministic + wire into CI (UN-3666) The workers test suite never ran in CI: the rig's `unit-workers` group filtered `-m "unit"` but no test carries that marker, so zero tests were collected and exit-5 mapped to success. Turning it on surfaced a hang plus ~28 order-dependent failures. This makes the suite deterministic in a single process and wires it into the rig. Determinism (conftest.py): - Reset os.environ to the .env.test baseline around every test (kills ambient dev-.env bleed + bare-env-write leaks). - Reset queue_backend thread-locals + one-shot log flags between tests. - isolated_celery_registry fixture: a genuinely-empty Celery app for the empty-registry guard tests (fixes the infinite-poll-loop hang caused by Celery's process-global shared_task backlog). - Auto-mark real-Postgres tests `integration` via a collection hook; the unit lane runs `-m "not integration"`. Isolation fixes: - Empty-app consumer tests: set_as_current=False (stop current_app hijack). - test_legacy_executor_scaffold: snapshot/restore ExecutorRegistry (was wiping the global registry); restore the exceptions-module namespace after reload (was rebinding exception classes and breaking pytest.raises identity suite-wide). - Barrier protocol test: bind AsyncResult to a backend-less probe app. Drifted tests (production refactored, tests never updated because they never ran): - ExecutionDispatcher -> get_executor_dispatcher (sanity phase3/phase5). - New execution_id arg (phase6h); lookup-enrichment plugin (phase6c); flush_pending_usage stub (phase2); shim-kwargs subset (phase1); PG-barrier __all__ exports (queue_backend_seam). CI wiring: - groups.yaml: unit-workers -> `-m "not integration"`; new integration-workers lane (requires postgres; optional until CI provisions a migrated DB and sets REQUIRE_PG_TESTS=1). - rig: exit-5 (no tests collected) now fails a required group. - pyproject: --timeout=120 hang safety net + pytest-timeout dep. Unit lane: 1133 passed, deterministic (was 28 failing + a hang). Integration lane: 103/104 (one pre-existing real-PG concurrency test, now isolated). Co-Authored-By: Claude Fable 5 * UN-3667 [TEST] Address PR review — enforce REQUIRE_PG_TESTS centrally + tighten fixtures F1 (HIGH): the REQUIRE_PG_TESTS hard-fail only covered pg_conn/pg_client/dedup_db; barrier_db/lock_db/barrier_conn used plain pytest.skip and ignored the flag, so the integration lane could go green having skipped the barrier/leader-election/reaper suites. Enforce centrally: a pytest_runtest_makereport hook turns any skipped `integration`-marked test into a failure when REQUIRE_PG_TESTS is set — covers every real-PG fixture, present and future, regardless of skip mechanism. F2: mark test_create_pg_connection_is_non_autocommit `integration` (opens a real connection inline via create_pg_connection, no fixture — was silently left in the DB-free unit lane, risking a connect-stall now bounded by --timeout). F3/F7: narrow the queue_backend reset guard from `except Exception` to suppress(ImportError) so a renamed reset target surfaces loudly instead of silently no-op'ing. This immediately exposed a latent bug the broad guard was hiding: queue_backend re-exports a `dispatch` function that shadows the submodule, so `import queue_backend.dispatch` bound the function and the dispatch reset had never run. Load the module via importlib.import_module. Also close cached connections before dropping the thread-locals. F4/F5 (comment accuracy): clearing Celery's finalizer backlog removes the celery.* built-ins too, so an isolated app has *no* tasks (not "only celery.* built-ins") — fix the wording in conftest + consumer test. Rework test_registered_task_count_excludes_celery_builtins to register a celery.*-named task and assert it's excluded, so it genuinely exercises the filter its name claims. F8: match plugin loads by positional *or* keyword name and assert the sibling lookup-enrichment plugin was loaded, so the highlight-skipped filter can't pass vacuously. Unit lane: 1107 passed, deterministic. F1 hook verified: skip->fail under REQUIRE_PG_TESTS, skip (green) without it. Co-Authored-By: Claude Fable 5 * UN-3667 [TEST] Address Greptile review — modern wrapper=True hook + exit-5 comment G1: switch pytest_runtest_makereport from the legacy hookwrapper=True to the wrapper=True API (pytest>=8; the workers pin >=9.0.3) — `yield` returns the report directly, no outcome.get_result(). Verified: skip->fail under REQUIRE_PG_TESTS, skip (green) without it; unit lane 1107 passed / deterministic. G2: clarify the _NON_FAILING_PYTEST_EXIT_CODES comment — exit-5 is non-failing for *optional* groups only; a required group that collects nothing fails via the explicit guard in cmd_run. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- tests/groups.yaml | 25 ++- tests/rig/cli.py | 11 + uv.lock | 1 + workers/pyproject.toml | 6 + workers/tests/conftest.py | 212 +++++++++++++++++- workers/tests/test_barrier.py | 11 +- .../tests/test_legacy_executor_scaffold.py | 50 +++-- workers/tests/test_pg_barrier.py | 7 +- workers/tests/test_pg_queue_consumer.py | 47 ++-- workers/tests/test_phase1_log_streaming.py | 29 ++- workers/tests/test_queue_backend_seam.py | 6 + workers/tests/test_sanity_phase2.py | 7 +- workers/tests/test_sanity_phase3.py | 2 +- workers/tests/test_sanity_phase5.py | 2 +- workers/tests/test_sanity_phase6c.py | 16 +- workers/tests/test_sanity_phase6h.py | 1 + 16 files changed, 382 insertions(+), 51 deletions(-) diff --git a/tests/groups.yaml b/tests/groups.yaml index 357f18f30f..1e92d9f3d9 100644 --- a/tests/groups.yaml +++ b/tests/groups.yaml @@ -70,10 +70,33 @@ groups: tier: unit workdir: workers paths: [tests, shared/tests] - markers: "unit" + # Run everything that isn't a real-Postgres test. The workers suite doesn't + # tag its unit tests with an explicit `unit` marker (there are ~1100 of + # them), so select by exclusion: `conftest.py` marks the real-DB tests + # `integration`, and this lane runs the rest. (`markers: "unit"` here + # previously matched *zero* tests — the suite silently never ran in CI.) + markers: "not integration" uv_sync_group: test coverage_source: shared + integration-workers: + tier: integration + workdir: workers + paths: [tests, shared/tests] + # Real-Postgres tests: barrier-decrement races, reaper, batch dedup, leader + # election, result backend. `conftest.py` marks these `integration` (any + # test using a real-connection fixture). They skip when Postgres is + # unreachable or the pg_queue schema isn't migrated. + markers: "integration" + uv_sync_group: test + coverage_source: shared + requires_services: [postgres] + # Optional until CI provisions a *migrated* database and sets + # REQUIRE_PG_TESTS=1 — that env flips the graceful skips into hard failures + # (see conftest `_skip_or_fail_no_pg`) so the lane can't pass having run + # nothing. Until then it runs-and-skips against a bare Postgres. + optional: true + unit-backend: tier: unit workdir: backend diff --git a/tests/rig/cli.py b/tests/rig/cli.py index 2876c42a97..71b9d44de7 100644 --- a/tests/rig/cli.py +++ b/tests/rig/cli.py @@ -38,6 +38,9 @@ # Pytest exit codes that the rig treats as non-failure for aggregation: # 0 — all tests passed # 5 — no tests collected (optional placeholders, empty hurl group, etc.) +# NOTE: exit-5 is non-failing here for *optional* groups only. A required +# group that collects nothing (exit 5) is caught by the explicit guard in +# cmd_run ("Empty collection (exit 5) is a failure…") and fails loudly. _NON_FAILING_PYTEST_EXIT_CODES = (0, 5) @@ -410,6 +413,14 @@ def cmd_run(args: argparse.Namespace) -> int: and overall_exit == 0 ): overall_exit = exit_code + # Empty collection (exit 5) is a *failure* for a required group: a + # marker filter or path that matches nothing (e.g. the `unit-workers` + # regression where `-m "unit"` matched zero tests) would otherwise + # pass silently with an empty junit — the whole suite "green" having + # run nothing. Optional groups (placeholders / infra-gated) keep + # exit 5 as a pass; that's what `not group.optional` guards. + if exit_code == 5 and not group.optional and overall_exit == 0: + overall_exit = exit_code # Belt-and-braces: if the junit attests to errors/failures the exit # code didn't (truncated junit → errors=1 with exit 0), the report # shows ❌ but exit would otherwise stay 0. Keep them in sync. diff --git a/uv.lock b/uv.lock index 1bce727c15..aa17b0c687 100644 --- a/uv.lock +++ b/uv.lock @@ -3992,6 +3992,7 @@ test = [ { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "pytest-mock", specifier = ">=3.11.0" }, + { name = "pytest-timeout", specifier = ">=2.3.1" }, { name = "responses", specifier = ">=0.23.0" }, ] diff --git a/workers/pyproject.toml b/workers/pyproject.toml index 618e9b72b8..b5c3c112aa 100644 --- a/workers/pyproject.toml +++ b/workers/pyproject.toml @@ -63,6 +63,7 @@ test = [ "pytest-asyncio>=0.24.0", "pytest-mock>=3.11.0", "pytest-cov>=4.1.0", + "pytest-timeout>=2.3.1", "factory-boy>=3.3.0", "responses>=0.23.0" ] @@ -158,6 +159,11 @@ addopts = [ "--strict-markers", "--strict-config", "--verbose", + # Hang safety net: no single test should run for minutes. Catches a wedged + # poll loop / deadlock in CI instead of hanging the job until it times out. + # Generous enough (120s) that only a genuine hang trips it, not a slow test. + "--timeout=120", + "--timeout-method=thread", "--cov=shared", "--cov=api_deployment", "--cov=general", diff --git a/workers/tests/conftest.py b/workers/tests/conftest.py index 2939c1aa89..d30d784cf5 100644 --- a/workers/tests/conftest.py +++ b/workers/tests/conftest.py @@ -6,6 +6,8 @@ time if INTERNAL_API_BASE_URL is not set. """ +import contextlib +import importlib import os from pathlib import Path @@ -16,6 +18,110 @@ _env_test = Path(__file__).resolve().parent.parent / ".env.test" load_dotenv(_env_test) +# Pristine baseline: the environment right after .env.test loads, captured before +# pytest collection imports any worker module. Some worker modules call +# ``load_dotenv(/.env)`` at import time, so on a developer machine the +# ambient dev ``.env`` (e.g. WORKER_BARRIER_KEY_TTL_SECONDS=180) bleeds into the +# process during collection and silently overrides test defaults — green in CI +# (no ``.env``), red locally. ``_restore_os_environ`` resets to this baseline +# around every test so the suite is deterministic regardless of ambient ``.env``. +_PRISTINE_ENVIRON = dict(os.environ) + + +# --- Collection: separate real-Postgres tests from the unit lane --- + + +def pytest_collection_modifyitems(config, items): + """Auto-mark real-Postgres tests as ``integration``. + + Any test that requests a real-Postgres fixture needs a live database. + Marking those ``integration`` here — rather than by hand on each of ~100 tests + scattered across mixed unit/integration files — lets the fast lane run + ``-m "not integration"`` (DB-free, deterministic) while a dedicated + integration lane runs ``-m integration`` against a provisioned Postgres. + + The set lists every fixture that opens a real connection directly; fixtures + layered on top of these (e.g. ``pg_client``/``result_backend`` build on + ``pg_conn``) are covered transitively because ``fixturenames`` includes the + whole dependency graph. + """ + pg_fixtures = { + "pg_conn", + "pg_client", + "barrier_db", + "dedup_db", + "lock_db", + "barrier_conn", + } + for item in items: + if pg_fixtures & set(getattr(item, "fixturenames", ())): + item.add_marker(pytest.mark.integration) + + +@pytest.hookimpl(wrapper=True) +def pytest_runtest_makereport(item, call): + """Under ``REQUIRE_PG_TESTS``, a *skipped* integration test is a failure. + + The integration lane sets ``REQUIRE_PG_TESTS`` precisely so it can't pass + having run nothing. Real-Postgres fixtures skip when the DB is unreachable or + unmigrated, and not all of them route through ``_skip_or_fail_no_pg`` (some + call ``pytest.skip`` directly). Rather than depend on every fixture — present + and future — using the right helper, enforce the guarantee centrally: any + ``integration``-marked test that skips during setup while the flag is set is + turned into a failure. This closes the "green having exercised none of them" + gap for the barrier / leader-election / reaper suites regardless of skip + mechanism. + + Uses the modern ``wrapper=True`` hook (pytest>=8) — ``yield`` returns the + report directly, and the wrapper returns it back. + """ + report = yield + if ( + os.getenv("REQUIRE_PG_TESTS") + and report.when == "setup" + and report.skipped + and item.get_closest_marker("integration") is not None + ): + report.outcome = "failed" + report.longrepr = ( + f"REQUIRE_PG_TESTS is set but integration test skipped " + f"(Postgres unreachable/unmigrated): {item.nodeid}. The integration " + f"lane must exercise the real-Postgres paths, not skip them." + ) + return report + + +# --- Isolation: keep the suite deterministic in a single process --- + + +@pytest.fixture(autouse=True) +def _restore_os_environ(): + """Reset ``os.environ`` to the ``.env.test`` baseline around every test. + + Two leaks are neutralised: (1) a test that mutates the environment (a bare + ``os.environ[...] =`` rather than ``monkeypatch.setenv``) leaking into later + tests, and (2) the ambient dev ``.env`` pulled in during collection (see + ``_PRISTINE_ENVIRON``) silently overriding test defaults. Resetting to the + pristine baseline both before and after each test makes every test start from + the same environment regardless of run order or the developer's ``.env``. + Defined first so it wraps every other autouse fixture. + """ + + def _reset_to_pristine(): + # Preserve pytest's own bookkeeping (e.g. PYTEST_CURRENT_TEST, which + # pytest sets per-test and deletes itself at teardown) — clearing it + # would make pytest's teardown KeyError. + preserved = {k: v for k, v in os.environ.items() if k.startswith("PYTEST")} + os.environ.clear() + os.environ.update(_PRISTINE_ENVIRON) + os.environ.update(preserved) + + _reset_to_pristine() + try: + yield + finally: + _reset_to_pristine() + # --- Shared PG-queue integration fixtures (real Postgres) --- # @@ -33,18 +139,34 @@ def integration_pg_conn(): return create_pg_connection(env_prefix="TEST_DB_") +def _skip_or_fail_no_pg(reason: str): + """Skip the test, or fail loudly when ``REQUIRE_PG_TESTS`` is set. + + The unit lane skips real-Postgres tests when no DB is around. The dedicated + integration lane sets ``REQUIRE_PG_TESTS=1`` so a missing/misconfigured + Postgres is a hard failure instead of a green-but-silent skip — otherwise the + integration lane could pass having run nothing. + """ + if os.getenv("REQUIRE_PG_TESTS"): + pytest.fail(reason) + pytest.skip(reason) + + @pytest.fixture def pg_conn(): - """A real connection; skips if Postgres is unreachable or unmigrated.""" + """A real connection; skips if Postgres is unreachable or unmigrated. + + Fails instead of skipping when ``REQUIRE_PG_TESTS`` is set (integration lane). + """ try: conn = integration_pg_conn() except psycopg2.OperationalError as exc: - pytest.skip(f"Postgres not reachable: {exc}") + _skip_or_fail_no_pg(f"Postgres not reachable: {exc}") with conn.cursor() as cur: cur.execute("SELECT to_regclass('pg_queue_message')") if cur.fetchone()[0] is None: conn.close() - pytest.skip("pg_queue migration not applied (run backend migrate)") + _skip_or_fail_no_pg("pg_queue migration not applied (run backend migrate)") yield conn conn.rollback() conn.close() @@ -56,3 +178,87 @@ def pg_client(pg_conn): from queue_backend.pg_queue import PgQueueClient return PgQueueClient(conn=pg_conn) + + +# --- Test-isolation fixtures (deterministic single-process runs) --- +# +# The workers suite carries several process-global registries/singletons that +# leak state across tests when the whole suite runs in one process (green in +# isolation, order-dependent failures / hangs together). The fixtures below +# snapshot-and-restore them so the suite is deterministic regardless of order. + + +@pytest.fixture +def isolated_celery_registry(): + """Give the test a genuinely empty Celery task registry. + + A truly empty ``Celery(...)`` app is impossible once any ``@shared_task`` + has been imported: Celery keeps a *process-global* finalizer backlog + (``celery._state._on_app_finalizers``) and injects every declared shared + task into every *new* app on finalize. So ``Celery("empty")`` created in a + full-suite run actually carries the worker's shared tasks — which silently + defeats the consumer's empty-registry guard (the guard passes, ``run()`` + enters its poll loop, and the test hangs). + + This fixture snapshots and clears that backlog for the test's duration, so a + ``Celery(...)`` created inside the test starts with *no tasks at all* — + neither the worker's shared tasks nor Celery's own ``celery.*`` built-ins, + since both are registered through this same finalizer backlog — then restores + it so the backlog isn't mutated for other tests. + """ + import celery._state as celery_state + + saved = set(celery_state._on_app_finalizers) + celery_state._on_app_finalizers.clear() + try: + yield + finally: + celery_state._on_app_finalizers.clear() + celery_state._on_app_finalizers.update(saved) + + +@pytest.fixture(autouse=True) +def _reset_queue_backend_state(): + """Reset process-global queue_backend state after every test. + + ``queue_backend`` caches a PG dispatch client and a barrier connection in + thread-locals, and trips one-shot "log this once" flags (routing allow-list, + per-task PG-routing notice). These are lazily populated on first use, so a + test that seeds a fake client or trips a log-once flag would otherwise change + what every later test in the same process observes — making the suite + order-dependent (green alone, failing in a full run). Reset on teardown so + each test starts from the same clean module state regardless of order. + + Only the *import* is guarded (a module absent in a given lane must not break + the autouse fixture). The resets run outside that guard on purpose: if a + reset target is ever renamed/removed, the resulting AttributeError surfaces + loudly here rather than turning the whole fixture into a silent no-op — which + would let order-dependence and hangs creep back with no failing test. + """ + yield + with contextlib.suppress(ImportError): + import queue_backend.routing as _routing + + _routing._allow_list_logged = False + with contextlib.suppress(ImportError): + # queue_backend re-exports a ``dispatch`` *function*, which shadows the + # submodule as a package attribute — so ``import queue_backend.dispatch`` + # would bind the function, not the module. Load the module explicitly. + _dispatch = importlib.import_module("queue_backend.dispatch") + + _dispatch._pg_routing_logged.clear() + client = getattr(_dispatch._pg_local, "client", None) + if client is not None: + # Close before dropping so a real libpq connection isn't leaked to + # GC/__del__ (matters in a large real-Postgres run). + with contextlib.suppress(Exception): + client.close() + del _dispatch._pg_local.client + with contextlib.suppress(ImportError): + import queue_backend.pg_barrier as _pg_barrier + + conn = getattr(_pg_barrier._local, "conn", None) + if conn is not None: + with contextlib.suppress(Exception): + conn.close() + _pg_barrier._local.conn = None diff --git a/workers/tests/test_barrier.py b/workers/tests/test_barrier.py index c35b088bd8..a7c95ab5c2 100644 --- a/workers/tests/test_barrier.py +++ b/workers/tests/test_barrier.py @@ -93,6 +93,7 @@ def test_barrier_handle_protocol_satisfied_by_celery_async_result(self): tests pass identically even if ``BarrierHandle`` were deleted; this one is load-bearing. """ + from celery import Celery from celery.result import AsyncResult # Assert against a real ``AsyncResult`` *instance* — that's @@ -102,6 +103,14 @@ def test_barrier_handle_protocol_satisfied_by_celery_async_result(self): # the smallest-blast-radius equivalent of "production sees # this object and reads ``.id``". # + # Bind to an explicit backend-less probe app (not the ambient + # ``current_app``): whichever worker app happens to be current in a + # full-suite run may carry a real ``db+postgresql://`` result backend, + # and binding ``AsyncResult`` to it would try to connect. ``.id`` is the + # constructor argument and needs no backend, so this stays a pure + # protocol-shape check. + probe_app = Celery("barrier-protocol-probe", set_as_current=False) + # # ``required_attrs`` is hand-maintained: if a future refactor # adds a required attribute to ``TaskHandle`` / # ``BarrierHandle``, add it here too. The hand-maintained form @@ -111,7 +120,7 @@ def test_barrier_handle_protocol_satisfied_by_celery_async_result(self): # ``__annotations__`` introspection would also work (and # doesn't require ``@runtime_checkable``), but the explicit # tuple keeps the contract surface explicit. - async_result_instance = AsyncResult("placeholder-task-id") + async_result_instance = AsyncResult("placeholder-task-id", app=probe_app) required_attrs = ("id",) missing = [ a for a in required_attrs if not hasattr(async_result_instance, a) diff --git a/workers/tests/test_legacy_executor_scaffold.py b/workers/tests/test_legacy_executor_scaffold.py index 48789c218d..2bce59b2d8 100644 --- a/workers/tests/test_legacy_executor_scaffold.py +++ b/workers/tests/test_legacy_executor_scaffold.py @@ -22,10 +22,21 @@ @pytest.fixture(autouse=True) def _clean_registry(): - """Ensure a clean executor registry for every test.""" + """Give each test a clean executor registry, then restore the prior state. + + ``ExecutorRegistry`` is a process-global singleton that worker modules + populate at import (e.g. ``executor.worker`` registers ``legacy``). A bare + ``clear()`` on teardown would wipe those registrations for every later test + in the suite — breaking structure-tool/dispatch tests that rely on a + populated registry, and causing "already registered" collisions. Snapshot + the registry, clear it for this test, then restore the snapshot so the global + state is left exactly as we found it. + """ + saved = dict(ExecutorRegistry._registry) ExecutorRegistry.clear() yield - ExecutorRegistry.clear() + ExecutorRegistry._registry.clear() + ExecutorRegistry._registry.update(saved) def _register_legacy(): @@ -254,18 +265,31 @@ def test_no_flask_import(self): import importlib import sys - # Ensure fresh import + # Reload to re-run the module's imports so a stray Flask import would + # re-populate sys.modules. But reloading rebinds the module's classes + # (LegacyExecutorError etc.) to new objects, which breaks ``isinstance`` + # / ``pytest.raises`` identity for every other test/module that imported + # them earlier. So snapshot the module namespace and restore it in a + # ``finally`` — the exception classes stay identical for the rest of the + # suite. mod_name = "executor.executors.exceptions" - if mod_name in sys.modules: - importlib.reload(sys.modules[mod_name]) - else: - importlib.import_module(mod_name) - - # Check that no flask modules were pulled in - flask_modules = [m for m in sys.modules if m.startswith("flask")] - assert flask_modules == [], ( - f"Flask modules imported: {flask_modules}" - ) + mod = sys.modules.get(mod_name) + saved = dict(mod.__dict__) if mod is not None else None + try: + if mod is not None: + importlib.reload(mod) + else: + importlib.import_module(mod_name) + + # Check that no flask modules were pulled in + flask_modules = [m for m in sys.modules if m.startswith("flask")] + assert flask_modules == [], ( + f"Flask modules imported: {flask_modules}" + ) + finally: + if saved is not None: + mod.__dict__.clear() + mod.__dict__.update(saved) def test_custom_data_error_signature(self): from executor.executors.exceptions import CustomDataError diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index 8a5fee1864..416f65795e 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -448,13 +448,18 @@ def test_wrapper_fresh_conn_not_retried_end_to_end( assert sleeps == [] # a fresh-conn death is not retried → no backoff +@pytest.mark.integration def test_create_pg_connection_is_non_autocommit(): """The decrement phase-split's exactly-once safety rests on the connection being non-autocommit (an uncommitted UPDATE rolls back on disconnect, so an execute-phase failure is never durable). Pin that at its SOURCE — a future ``conn.autocommit = True`` in ``create_pg_connection`` would silently reintroduce the double-count the split exists to prevent, and no other test - would catch it (the barrier_db fixture sets autocommit itself).""" + would catch it (the barrier_db fixture sets autocommit itself). + + Opens a real connection inline (no fixture), so it's marked ``integration`` + explicitly — the collection hook keys off fixture names and wouldn't catch + it, which would otherwise leave it in the DB-free unit lane.""" os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") try: conn = create_pg_connection(env_prefix="TEST_DB_") diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index 5a731b7b9f..9ccbd1ab85 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -217,12 +217,16 @@ def _sleep(secs): consumer.run(install_signals=False) # must not raise assert client.read.call_count == 2 # kept polling after the error - def test_run_refuses_to_start_with_empty_registry(self): + def test_run_refuses_to_start_with_empty_registry(self, isolated_celery_registry): # A non-bootstrapped consumer would drop every message as "unknown" — - # fail loudly instead. + # fail loudly instead. isolated_celery_registry makes "empty" genuinely + # empty (Celery's global shared_task backlog would otherwise leak the + # worker's tasks in and let the guard pass → infinite poll loop / hang). from celery import Celery - empty_app = Celery("empty-no-tasks") # only celery.* built-ins + # No tasks at all: isolated_celery_registry cleared the finalizer backlog, + # which holds the worker's shared tasks *and* Celery's celery.* built-ins. + empty_app = Celery("empty-no-tasks", set_as_current=False) consumer = PgQueueConsumer(["q"], client=MagicMock(), app=empty_app) with pytest.raises(RuntimeError, match="no application tasks"): consumer.run(install_signals=False) @@ -239,12 +243,14 @@ def test_run_starts_with_registered_tasks(self, monkeypatch): consumer.run(install_signals=False) # must not raise assert client.read.called - def test_run_bypasses_guard_when_require_tasks_false(self, monkeypatch): + def test_run_bypasses_guard_when_require_tasks_false( + self, monkeypatch, isolated_celery_registry + ): # require_tasks=False skips the guard even on an empty registry (lets a # caller opt out, e.g. a deliberately task-less smoke run). from celery import Celery - empty_app = Celery("empty-no-tasks") + empty_app = Celery("empty-no-tasks", set_as_current=False) client = MagicMock() client.read.return_value = [] consumer = PgQueueConsumer(["q"], client=client, app=empty_app) @@ -254,25 +260,32 @@ def test_run_bypasses_guard_when_require_tasks_false(self, monkeypatch): consumer.run(install_signals=False, require_tasks=False) # must not raise assert client.read.called - def test_registered_task_count_excludes_celery_builtins(self): - # The guard counts application tasks only — celery.* built-ins (present - # in every app) must not mask an un-bootstrapped registry. + def test_registered_task_count_excludes_celery_builtins( + self, isolated_celery_registry + ): + # The guard counts *application* tasks only — a celery.*-named task must + # not count toward the total. isolated_celery_registry clears the + # finalizer backlog so the app starts genuinely empty; we then register + # one celery.*-named task and one application task and assert only the + # latter is counted (exercising the exclusion filter under test). from celery import Celery - empty_app = Celery("empty-no-tasks") - assert ( - PgQueueConsumer(["q"], client=MagicMock(), app=empty_app)._registered_task_count() - == 0 - ) + empty_app = Celery("empty-no-tasks", set_as_current=False) + consumer = PgQueueConsumer(["q"], client=MagicMock(), app=empty_app) + assert consumer._registered_task_count() == 0 # genuinely empty + + @empty_app.task(name="celery.builtin_like") + def _builtin_like(): + return 0 + + # A celery.*-named task is excluded → still zero application tasks. + assert consumer._registered_task_count() == 0 @empty_app.task(name="test_pg_consumer.demo") def _demo(): return 1 - assert ( - PgQueueConsumer(["q"], client=MagicMock(), app=empty_app)._registered_task_count() - == 1 - ) + assert consumer._registered_task_count() == 1 # --- Liveness heartbeat (drives the health endpoint) --- diff --git a/workers/tests/test_phase1_log_streaming.py b/workers/tests/test_phase1_log_streaming.py index 9c063e19de..022261ffc4 100644 --- a/workers/tests/test_phase1_log_streaming.py +++ b/workers/tests/test_phase1_log_streaming.py @@ -347,11 +347,18 @@ def test_extract_passes_log_info_to_shim( result = executor.execute(ctx) assert result.success - mock_shim_cls.assert_called_once_with( - platform_api_key="sk-test", - log_events_id="session-abc", - component={"tool_id": "t1", "run_id": "r1", "doc_name": "test.pdf"}, - ) + # Assert the log-info kwargs specifically (the point of this test) rather + # than the full constructor — the shim gained execution/org/file-exec ids + # unrelated to log passthrough. + mock_shim_cls.assert_called_once() + shim_kwargs = mock_shim_cls.call_args.kwargs + assert shim_kwargs["platform_api_key"] == "sk-test" + assert shim_kwargs["log_events_id"] == "session-abc" + assert shim_kwargs["component"] == { + "tool_id": "t1", + "run_id": "r1", + "doc_name": "test.pdf", + } @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") @@ -389,11 +396,13 @@ def test_extract_no_log_info_when_absent( result = executor.execute(ctx) assert result.success - mock_shim_cls.assert_called_once_with( - platform_api_key="sk-test", - log_events_id="", - component={}, - ) + # Assert the log-info kwargs specifically (see sibling test); the shim's + # other constructor args are unrelated to this test's concern. + mock_shim_cls.assert_called_once() + shim_kwargs = mock_shim_cls.call_args.kwargs + assert shim_kwargs["platform_api_key"] == "sk-test" + assert shim_kwargs["log_events_id"] == "" + assert shim_kwargs["component"] == {} @patch( "executor.executors.legacy_executor.LegacyExecutor._get_prompt_deps" diff --git a/workers/tests/test_queue_backend_seam.py b/workers/tests/test_queue_backend_seam.py index f1714eff28..abc0ac1547 100644 --- a/workers/tests/test_queue_backend_seam.py +++ b/workers/tests/test_queue_backend_seam.py @@ -287,16 +287,22 @@ def test_all_exports(self): # Phase 8a adds QueueBackend + select_backend — the queue-transport # routing gate that the WORKER_PG_QUEUE_ENABLED_TASKS allow-list # drives. + # The PG-queue barrier work adds the Postgres barrier surface: + # PgBarrier + its barrier_pg_decr_and_check / barrier_pg_abort tasks, + # selected when WORKER_BARRIER_BACKEND routes to the PG backend. assert set(queue_backend.__all__) == { "Barrier", "BarrierBackend", "BarrierHandle", "CeleryChordBarrier", "FairnessKey", + "PgBarrier", "QueueBackend", "RedisDecrBarrier", "barrier_abort", "barrier_decr_and_check", + "barrier_pg_abort", + "barrier_pg_decr_and_check", "dispatch", "get_barrier", "select_backend", diff --git a/workers/tests/test_sanity_phase2.py b/workers/tests/test_sanity_phase2.py index 18a87e51d3..0dcc8f92af 100644 --- a/workers/tests/test_sanity_phase2.py +++ b/workers/tests/test_sanity_phase2.py @@ -629,7 +629,12 @@ def test_index_contract(self, mock_deps, mock_get_fs, eager_app): mock_index_cls.return_value = mock_index mock_emb_cls = MagicMock() - mock_emb_cls.return_value = MagicMock() + mock_emb = MagicMock() + # Production records embedding usage via flush_pending_usage() into the + # result metadata; stub it to a JSON-serialisable value so the round-trip + # below doesn't choke on a bare MagicMock. + mock_emb.flush_pending_usage.return_value = [] + mock_emb_cls.return_value = mock_emb mock_vdb_cls = MagicMock() mock_vdb_cls.return_value = MagicMock() diff --git a/workers/tests/test_sanity_phase3.py b/workers/tests/test_sanity_phase3.py index d9cf076980..d873756b01 100644 --- a/workers/tests/test_sanity_phase3.py +++ b/workers/tests/test_sanity_phase3.py @@ -19,7 +19,7 @@ # --------------------------------------------------------------------------- _PATCH_DISPATCHER = ( - "file_processing.structure_tool_task.ExecutionDispatcher" + "file_processing.structure_tool_task.get_executor_dispatcher" ) _PATCH_PLATFORM_HELPER = ( "file_processing.structure_tool_task._create_platform_helper" diff --git a/workers/tests/test_sanity_phase5.py b/workers/tests/test_sanity_phase5.py index e414d9d2d5..1bda3fa177 100644 --- a/workers/tests/test_sanity_phase5.py +++ b/workers/tests/test_sanity_phase5.py @@ -866,7 +866,7 @@ class TestStructureToolSingleDispatch: "file_processing.structure_tool_task._create_platform_helper" ) @patch( - "file_processing.structure_tool_task.ExecutionDispatcher" + "file_processing.structure_tool_task.get_executor_dispatcher" ) def test_single_dispatch_normal( self, diff --git a/workers/tests/test_sanity_phase6c.py b/workers/tests/test_sanity_phase6c.py index 54388f6fee..76aa3797cf 100644 --- a/workers/tests/test_sanity_phase6c.py +++ b/workers/tests/test_sanity_phase6c.py @@ -432,8 +432,20 @@ def test_highlight_skipped_when_disabled( result = executor._handle_answer_prompt(ctx) assert result.success - # Plugin loader should NOT have been called - mock_plugin_get.assert_not_called() + # The highlight plugin must NOT be loaded when highlight is disabled. + # Other plugins (e.g. lookup-enrichment) load independently of highlight, + # so assert on the highlight plugin specifically rather than "never + # called". Read the plugin name from positional *or* keyword args so the + # check can't silently pass if the call form changes. + loaded_plugins = [ + (call.args[0] if call.args else call.kwargs.get("name")) + for call in mock_plugin_get.call_args_list + ] + assert "highlight-data" not in loaded_plugins + # Guard against a vacuous pass: the loader IS exercised on this path — + # lookup-enrichment loads regardless of highlight — so if nothing shows + # up here the filter above would be meaningless. + assert "lookup-enrichment" in loaded_plugins # process_text should be None llm_complete_call = mock_llm.complete.call_args assert llm_complete_call.kwargs.get("process_text") is None diff --git a/workers/tests/test_sanity_phase6h.py b/workers/tests/test_sanity_phase6h.py index 3b0ed2039c..a704211a75 100644 --- a/workers/tests/test_sanity_phase6h.py +++ b/workers/tests/test_sanity_phase6h.py @@ -224,6 +224,7 @@ def test_structure_tool_dispatches_agentic_extract(self, mock_x2text_cls, tmp_pa dispatcher=mock_dispatcher, shim=MagicMock(), file_execution_id="exec-001", + execution_id="wf-exec-001", organization_id="org-001", source_file_name="test.pdf", fs=MagicMock(), From 93165671303790a367325deeabe52d803ec39732 Mon Sep 17 00:00:00 2001 From: muhammad-ali-e <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:34:24 +0000 Subject: [PATCH 54/65] Commit uv.lock changes --- workers/uv.lock | 4662 ++++++++++++++++++++++++----------------------- 1 file changed, 2338 insertions(+), 2324 deletions(-) diff --git a/workers/uv.lock b/workers/uv.lock index b28b90fceb..d360e6e11a 100644 --- a/workers/uv.lock +++ b/workers/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 1 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, upload-time = "2024-07-22T12:10:33.849Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/1e/6d5146676044247af566fa5843b335b1a647e6446070cec9c8b61c31b369/adlfs-2024.7.0.tar.gz", hash = "sha256:106995b91f0eb5e775bcd5957d180d9a14faef3271a063b1f65c66fd5ab05ddf", size = 48588 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/6f/51/a71c457bd0bc8af3e522b6999ff300852c7c446e384fd9904b0794f875df/adlfs-2024.7.0-py3-none-any.whl", hash = "sha256:2005c8e124fda3948f2a6abb2dbebb2c936d2d821acaca6afd61932edfa9bc07", size = 41349 }, ] [[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, upload-time = "2024-08-22T20:42:13.086Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/17/2f6305cc52976dea8156b56badc3602f162f86693a6cc8badc20d2c5cfe6/aiobotocore-2.13.3.tar.gz", hash = "sha256:ac5620f93cc3e7c2aef7c67ba2bb74035ff8d49ee2325821daed13b3dd82a473", size = 106736 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/81/98/1c1a1ba5d74f87cbabbf207d36a98551b082b8b187dc35f7808b43f4cf1a/aiobotocore-2.13.3-py3-none-any.whl", hash = "sha256:1272f765fd9414e1a68f8add71978367db94e17e36c3bf629cf1153eb5141fb9", size = 77194 }, ] [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, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, ] [[package]] @@ -67,99 +67,99 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402 }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310 }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448 }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854 }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884 }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034 }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054 }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278 }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795 }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397 }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504 }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806 }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707 }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121 }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580 }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771 }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873 }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073 }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882 }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270 }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841 }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088 }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564 }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998 }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918 }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657 }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907 }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565 }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018 }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416 }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881 }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572 }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137 }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953 }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479 }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077 }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688 }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094 }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662 }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748 }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723 }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531 }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718 }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918 }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014 }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398 }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018 }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462 }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824 }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898 }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114 }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541 }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776 }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329 }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293 }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756 }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052 }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888 }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679 }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021 }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574 }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773 }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001 }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809 }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320 }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077 }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476 }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347 }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465 }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423 }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906 }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095 }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222 }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922 }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035 }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512 }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571 }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159 }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409 }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166 }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255 }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640 }, ] [[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, upload-time = "2025-11-06T22:17:07.609Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182 }, ] [[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, upload-time = "2025-07-03T22:54:43.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, ] [[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, upload-time = "2025-12-23T19:25:43.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405 }, ] [[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, upload-time = "2024-11-12T19:55:44.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944 }, ] [[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, upload-time = "2025-11-10T22:07:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, ] [[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, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] [[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, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, ] [[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, upload-time = "2020-05-11T07:59:51.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, ] [[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, upload-time = "2022-03-15T14:46:52.889Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, ] [[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, upload-time = "2026-05-04T08:11:31.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473 }, ] [[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, upload-time = "2026-02-18T19:33:05.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/fe/5c7710bc611a4070d06ba801de9a935cc87c3d4b689c644958047bdf2cba/azure_core-1.38.2.tar.gz", hash = "sha256:67562857cb979217e48dc60980243b61ea115b77326fa93d83b729e7ff0482e7", size = 363734 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/42/23/6371a551800d3812d6019cd813acd985f9fac0fedc1290129211a73da4ae/azure_core-1.38.2-py3-none-any.whl", hash = "sha256:074806c75cf239ea284a33a66827695ef7aeddac0b4e19dda266a93e4665ead9", size = 217957 }, ] [[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, upload-time = "2023-05-10T21:17:05.665Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/ff/61369d06422b5ac48067215ff404841342651b14a89b46c8d8e1507c8f17/azure-datalake-store-0.0.53.tar.gz", hash = "sha256:05b6de62ee3f2a0a6e6941e6933b792b800c3e7f6ffce2fc324bc19875757393", size = 71430 } 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, upload-time = "2023-05-10T21:17:02.629Z" }, + { 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 }, ] [[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, upload-time = "2026-02-11T01:55:42.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423 }, ] [[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, upload-time = "2026-01-06T23:48:57.282Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/24/072ba8e27b0e2d8fec401e9969b429d4f5fc4c8d4f0f05f4661e11f7234a/azure_storage_blob-12.28.0.tar.gz", hash = "sha256:e7d98ea108258d29aa0efbfd591b2e2075fa1722a2fae8699f0b3c9de11eff41", size = 604225 } 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, upload-time = "2026-01-06T23:48:58.995Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499 }, ] [[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, upload-time = "2022-10-05T19:19:32.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 }, ] [[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, upload-time = "2026-04-27T12:15:22.021Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/51/08fb68d23f4b0f6256fe85dc86e9576941550f890b079352fba719e07b39/banks-2.4.2.tar.gz", hash = "sha256:cda6013bd377ea7b701933578bfb9370fc21ad70bc13cedfc3f5cb2c034ca3dc", size = 188633 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/00/b6/8dc5477681b782e2f99de703e7a99828883364b9e03a60d3e2c47053d56a/banks-2.4.2-py3-none-any.whl", hash = "sha256:5fe407cc48c101f3e13d1cf732b83b8246003337612f13c0705d2e81f6faffb7", size = 35050 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721 }, ] [[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, upload-time = "2024-02-18T19:09:05.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764 }, ] [[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, upload-time = "2025-11-30T13:28:48.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070 }, ] [[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, upload-time = "2026-03-12T03:36:03.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155 } 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, 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" }, + { 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 }, ] [[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, upload-time = "2024-08-15T19:25:38.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/c1/f80cfbe564c89cdb080cd9ac2079ce05a2fac869bf8fbc45929ed3190da9/boto3-1.34.162.tar.gz", hash = "sha256:873f8f5d2f6f85f1018cbb0535b03cceddc7b655b61f66a0a56995238804f41f", size = 108585 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/65/41/faa5081761be3bac3999f912996c14c4dc9d06eab86c234bd6441f54bd64/boto3-1.34.162-py3-none-any.whl", hash = "sha256:d6f6096bdab35a0c0deff469563b87d184a28df7689790f7fe7be98502b7c590", size = 139174 }, ] [[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, upload-time = "2024-08-15T19:25:25.162Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/17d672eac6725da49bd5832e3bd2f74c4d212311cd393fd56b59f51a4e86/botocore-1.34.162.tar.gz", hash = "sha256:adc23be4fb99ad31961236342b7cbf3c0bfc62532cd02852196032e8c0d682f3", size = 12676693 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/bc/47/e35f788047c91110f48703a6254e5c84e33111b3291f7b57a653ca00accf/botocore-1.34.162-py3-none-any.whl", hash = "sha256:2d918b02db88d27a75b48275e6fb2506e9adaaddbec1ffa6a8a0898b34e769be", size = 12468049 }, ] [[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, upload-time = "2023-08-23T19:24:35.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/de/1c5e0faec600538f6a1d41c7ce7834cacddb2237923e30ddb225254b74b9/boxfs-0.2.1.tar.gz", hash = "sha256:c1889e12f53be3216b44f088237ac0f367a7a759a53b01b0c0edf2b3d694e50f", size = 9523 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/86/bb/243d10169c8397051bad6bdd10beb2407fa490bfe01216f5fad09e066191/boxfs-0.2.1-py3-none-any.whl", hash = "sha256:ae796c30309bd5a02654fff9eddf1ed320356225568fad0e109e1942beaef72a", size = 9358 }, ] [[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, upload-time = "2025-04-09T15:07:15.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/d7/c1a95bb602d7f90a85a68d8e6f11954e50c255110d39e2167c7796252622/boxsdk-3.14.0.tar.gz", hash = "sha256:7918b1929368724662474fffa417fa0457a523d089b8185260efbedd28c4f9b1", size = 232630 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/4d/5d/4e15511e0f4f2f9fbbf4646a8d0e138e5c53a3d428f1724e7dc3c8acf556/boxsdk-3.14.0-py2.py3-none-any.whl", hash = "sha256:0314e2f172b050e98489955f2e9001263de79c3dd751e6feee19f2195fdf7c01", size = 141329 }, ] [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, upload-time = "2025-06-01T11:08:12.563Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775 }, ] [[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, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, ] [[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, 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" }, +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 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2024-12-21T18:38:44.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, ] [[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, upload-time = "2024-03-24T08:22:07.499Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631 }, ] [[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, upload-time = "2025-06-25T00:47:37.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343 } 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, upload-time = "2025-06-25T00:47:36.731Z" }, + { 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 }, ] [[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, upload-time = "2023-06-15T12:43:51.141Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289 }, ] [[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, upload-time = "2022-10-25T02:36:22.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2026-03-15T08:43:48.112Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2024-06-09T16:20:19.103Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 }, ] [[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, upload-time = "2026-01-29T23:03:28.199Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207 } 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, 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" }, + { 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 }, ] [[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, upload-time = "2025-02-24T04:41:34.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, ] [[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, upload-time = "2021-03-08T10:59:26.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, ] [[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, upload-time = "2025-01-27T10:46:25.7Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998 }, ] [[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, upload-time = "2020-04-20T14:23:38.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178 }, ] [[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, upload-time = "2022-11-28T23:32:33.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/04/d24f6e645ad82ba0ef092fa17d9ef7a21953781663648a01c9371d9e8e98/dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd", size = 30782 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197 }, ] [[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, upload-time = "2023-12-24T09:54:32.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, ] [[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, upload-time = "2023-06-01T14:24:49.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/73/f7c9a14e88e769f38cb7fb45aa88dfd795faa8e18aea11bababf6e068d5e/docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20", size = 259301 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/db/be/3032490fa33b36ddc8c4b1da3252c6f974e7133f1a50de00c6b85cca203a/docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9", size = 148096 }, ] [[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, upload-time = "2024-06-03T16:45:30.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/56/ac085f58e8e0d0bcafdf98c2605e454ac946e3d0c72679669ae112dc30be/dropbox-12.0.2.tar.gz", hash = "sha256:50057fd5ad5fcf047f542dfc6747a896e7ef982f1b5f8500daf51f3abd609962", size = 560236 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2d/de/95d8204d9a20fbdb353c5f8e4229b0fcb90f22b96f8246ff1f47c8a45fd5/dropbox-12.0.2-py3-none-any.whl", hash = "sha256:c5b7e9c2668adb6b12dcecd84342565dc50f7d35ab6a748d155cb79040979d1c", size = 572076 }, ] [[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, upload-time = "2024-05-27T14:04:37.648Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/15/6d8f4c3033ad2bc364b8bb613c52c96653f2268f32ecff4f3ab5f1d7c19b/dropboxdrivefs-1.4.1.tar.gz", hash = "sha256:6f3c6061d045813553ce91ed0e2b682f1d70bec74011943c92b3181faacefd34", size = 7413 } [[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, upload-time = "2025-02-03T09:49:04.433Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146 } 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, upload-time = "2025-02-03T09:49:01.659Z" }, + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036 }, ] [[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, upload-time = "2026-02-23T21:34:38.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/2a/96fff3edcb10f6505143448a4b91535f77b74865cec45be52690ee280443/faker-40.5.1.tar.gz", hash = "sha256:70222361cd82aa10cb86066d1a4e8f47f2bcdc919615c412045a69c4e6da0cd3", size = 1952684 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/4d/a9/1eed4db92d0aec2f9bfdf1faae0ab0418b5e121dda5701f118a7a4f0cd6a/faker-40.5.1-py3-none-any.whl", hash = "sha256:c69640c1e13bad49b4bcebcbf1b52f9f1a872b6ea186c248ada34d798f1661bf", size = 1987053 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2026-03-01T15:08:45.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427 }, ] [[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, upload-time = "2022-11-02T17:34:04.141Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970 }, ] [[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, upload-time = "2025-06-20T19:31:35.838Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2024-10-21T01:21:16.969Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/52/f16a068ebadae42526484c31f4398e62962504e5724a8ba5dc3409483df2/fsspec-2024.10.0.tar.gz", hash = "sha256:eda2d8a4116d4f2429db8550f2457da57279247dd930bb12f821b58391359493", size = 286853 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c6/b2/454d6e7f0158951d8a78c2e1eb4f69ae81beb8dca5fee9809c6c99e9d0d0/fsspec-2024.10.0-py3-none-any.whl", hash = "sha256:03b9a6785766a4de40368b88906366755e2819e758b83705c88cd7cb5fe81871", size = 179641 }, ] [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, upload-time = "2023-03-28T06:22:46.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b8/c6081521ff70afdff55cd9512b2220bbf4fa88804dae51d1b57b4b58ef32/funcy-2.0.tar.gz", hash = "sha256:3963315d59d41c6f30c04bc910e10ab50a3ac4a225868bfa96feed133df075cb", size = 537931 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl", hash = "sha256:53df23c8bb1651b12f095df764bfb057935d49537a56de211b098f4c79614bb0", size = 30891 }, ] [[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, upload-time = "2024-10-21T13:43:26.163Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/1e/1d8c4593d9e2eb04918fec43253ab152823d67ad51ad9e3ab6b3a78c431a/gcsfs-2024.10.0.tar.gz", hash = "sha256:5df54cfe568e8fdeea5aafa7fed695cdc69a9a674e991ca8c1ce634f5df1d314", size = 79588 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/dc/96/d60e835fb7d10166c77aef0c1fa30e634153c03a0f486786977b95f88fde/gcsfs-2024.10.0-py2.py3-none-any.whl", hash = "sha256:bb2d23547e61203ea2dda5fa6c4b91a0c34b74ebe8bb6ab1926f6c33381bceb2", size = 34953 }, ] [[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, upload-time = "2025-10-03T00:07:34.778Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266 } 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, upload-time = "2025-10-03T00:07:32.924Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489 }, ] [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, upload-time = "2026-02-12T00:38:03.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/4ab3e3516b93bb50ed7814738ea61d49cba3f72f4e331dc9518ae2731e92/google_api_python_client-2.190.0.tar.gz", hash = "sha256:5357f34552e3724d80d2604c8fa146766e0a9d6bb0afada886fafed9feafeef6", size = 14111143 } 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, upload-time = "2026-02-12T00:38:00.974Z" }, + { 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 }, ] [[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, upload-time = "2026-06-15T22:33:16.466Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400 }, ] [[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, upload-time = "2025-12-15T22:13:51.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134 } 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, upload-time = "2025-12-15T22:13:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529 }, ] [[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, upload-time = "2026-02-27T14:13:01.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/b4/1b19567e4c567b796f5c593d89895f3cfae5a38e04f27c6af87618fd0942/google_auth_oauthlib-1.3.0.tar.gz", hash = "sha256:cd39e807ac7229d6b8b9c1e297321d36fcc8a9e4857dff4301870985df51a528", size = 21777 } 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, upload-time = "2026-02-27T14:12:47.865Z" }, + { url = "https://files.pythonhosted.org/packages/2f/56/909fd5632226d3fba31d7aeffd4754410735d49362f5809956fe3e9af344/google_auth_oauthlib-1.3.0-py3-none-any.whl", hash = "sha256:386b3fb85cf4a5b819c6ad23e3128d975216b4cac76324de1d90b128aaf38f29", size = 19308 }, ] [[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, upload-time = "2023-07-19T23:12:12.7Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/ff/2c520952db184dec31e2ee988cfa37fa9e7776935a3f2eccc44252ecab5f/google-cloud-bigquery-3.11.4.tar.gz", hash = "sha256:697df117241a2283bcbb93b21e10badc14e51c9a90800d2a7e1a3e1c7d842974", size = 410777 } 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, upload-time = "2023-07-19T23:12:09.449Z" }, + { 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 }, ] [[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, upload-time = "2025-10-29T23:17:39.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027 } 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, upload-time = "2025-10-29T23:17:38.548Z" }, + { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469 }, ] [[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, upload-time = "2023-03-27T14:51:09.684Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/6b/92b705f408c1d928526b65d1259be4254ef1f45e620f01f8665156b4d781/google-cloud-secret-manager-2.16.1.tar.gz", hash = "sha256:149d11ce9be7ea81d4ac3544d3fcd4c716a9edb2cb775d9c075231570b079fbb", size = 128884 } 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, upload-time = "2023-03-27T14:51:07.661Z" }, + { 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 }, ] [[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, upload-time = "2023-05-04T17:56:46.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/50/c9998f84fd8ce8799d7f8020466bbc5c9e3b1126b04a09fdb02378d451b0/google-cloud-storage-2.9.0.tar.gz", hash = "sha256:9b6ae7b509fc294bdacb84d0f3ea8e20e2c54a8b4bbe39c5707635fec214eff3", size = 5498811 } 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, upload-time = "2023-05-04T17:56:43.929Z" }, + { 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 }, ] [[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, upload-time = "2025-12-16T00:35:25.142Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192 } 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, 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" }, + { 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 }, ] [[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, upload-time = "2025-11-17T15:38:06.659Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265 } 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, upload-time = "2025-11-17T15:38:05.594Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340 }, ] [[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, upload-time = "2025-11-06T18:29:24.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433 } 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, upload-time = "2025-11-06T18:29:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515 }, ] [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, 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" }, +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 }, ] [[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, upload-time = "2026-03-23T21:06:53.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/56/28a0accac339c164b52a92c6cfc45a903acc0c174caa5c1713803467b533/griffe-2.0.0.tar.gz", hash = "sha256:c68979cd8395422083a51ea7cf02f9c119d889646d99b7b656ee43725de1b80f", size = 293906 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214 }, ] [[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, upload-time = "2026-03-23T21:06:54.8Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/f8/2e129fd4a86e52e58eefe664de05e7d502decf766e7316cc9e70fdec3e18/griffecli-2.0.0.tar.gz", hash = "sha256:312fa5ebb4ce6afc786356e2d0ce85b06c1c20d45abc42d74f0cda65e159f6ef", size = 56213 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345 }, ] [[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, upload-time = "2026-03-23T21:06:55.954Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004 }, ] [[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, upload-time = "2025-10-15T21:14:53.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745 } 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, upload-time = "2025-10-15T21:14:51.72Z" }, + { 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 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2024-08-06T00:37:08.003Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/d7/013ef01c5a1c2fd0932c27c904934162f69f41ca0f28396d3ffe4d386123/grpcio-status-1.62.3.tar.gz", hash = "sha256:289bdd7b2459794a12cf95dc0cb727bd4a1742c37bd823f760236c937e53a485", size = 13063 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/90/40/972271de05f9315c0d69f9f7ebbcadd83bc85322f538637d11bb8c67803d/grpcio_status-1.62.3-py3-none-any.whl", hash = "sha256:f9049b762ba8de6b1086789d8315846e094edac2c50beaf462338b301a8fd4b8", size = 14448 }, ] [[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, upload-time = "2024-08-06T00:37:11.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fa/b69bd8040eafc09b88bb0ec0fea59e8aacd1a801e688af087cead213b0d0/grpcio-tools-1.62.3.tar.gz", hash = "sha256:7c7136015c3d62c3eef493efabaf9e3380e3e66d24ee8e94c01cb71377f57833", size = 4538520 } 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, 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" }, + { 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 }, ] [[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, upload-time = "2025-04-24T03:35:25.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, ] [[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, upload-time = "2025-08-23T18:12:19.778Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2025-01-22T21:44:58.347Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 }, ] [[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, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] [[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, upload-time = "2026-01-23T11:04:44.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099 }, ] [[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, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [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, upload-time = "2026-02-26T15:35:32.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/76/b5efb3033d8499b17f9386beaf60f64c461798e1ee16d10bc9c0077beba5/huggingface_hub-1.5.0.tar.gz", hash = "sha256:f281838db29265880fb543de7a23b0f81d3504675de82044307ea3c6c62f799d", size = 695872 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ec/74/2bc951622e2dbba1af9a460d93c51d15e458becd486e62c29cc0ccb08178/huggingface_hub-1.5.0-py3-none-any.whl", hash = "sha256:c9c0b3ab95a777fc91666111f3b3ede71c0cdced3614c553a64e98920585c4ee", size = 596261 }, ] [[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, upload-time = "2025-01-22T21:41:49.302Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 }, ] [[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, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340 }, ] [[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, upload-time = "2024-09-11T14:56:08.937Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, ] [[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, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] [[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, upload-time = "2025-10-11T00:36:35.172Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287 }, ] [[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, upload-time = "2024-10-08T23:04:11.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320 }, ] [[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, upload-time = "2026-02-28T10:08:20.685Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733 }, ] [[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, upload-time = "2025-03-05T20:05:02.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2026-01-22T16:35:26.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 }, ] [[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, upload-time = "2025-12-15T08:41:46.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071 }, ] [[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, upload-time = "2024-07-08T18:40:05.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, ] [[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, upload-time = "2025-09-08T01:34:59.186Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, ] [[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, upload-time = "2025-06-01T10:19:22.281Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -2066,9 +2066,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/55/aebffceaa08688a989e9c68b3edc3a520a1f8338eb0346668774bd66ad88/litellm-1.85.1.tar.gz", hash = "sha256:3b8ef0c89ff2736cbd27109f17ff31f1bd0ab59dee9be8cadb28ec3cb167ce0d", size = 15346324, upload-time = "2026-05-21T02:30:38.185Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/55/aebffceaa08688a989e9c68b3edc3a520a1f8338eb0346668774bd66ad88/litellm-1.85.1.tar.gz", hash = "sha256:3b8ef0c89ff2736cbd27109f17ff31f1bd0ab59dee9be8cadb28ec3cb167ce0d", size = 15346324 } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/a0/263a13c2253201aa11563a69d9a87f3510030aa765a16f57fc40ceefcdf5/litellm-1.85.1-py3-none-any.whl", hash = "sha256:c89eb5dfd18cce3d40b59e79c74f7f645bc7814a417c6ab25e53c786f0a6ab7b", size = 16980080, upload-time = "2026-05-21T02:30:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/17/a0/263a13c2253201aa11563a69d9a87f3510030aa765a16f57fc40ceefcdf5/litellm-1.85.1-py3-none-any.whl", hash = "sha256:c89eb5dfd18cce3d40b59e79c74f7f645bc7814a417c6ab25e53c786f0a6ab7b", size = 16980080 }, ] [[package]] @@ -2080,9 +2080,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, upload-time = "2025-07-28T17:22:06.41Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/72/816e6e900448e1b4a8137d90e65876b296c5264a23db6ae888bd3e6660ba/llama_cloud-0.1.35.tar.gz", hash = "sha256:200349d5d57424d7461f304cdb1355a58eea3e6ca1e6b0d75c66b2e937216983", size = 106403 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/1d/d2/8d18a021ab757cea231428404f21fe3186bf1ebaac3f57a73c379483fd3f/llama_cloud-0.1.35-py3-none-any.whl", hash = "sha256:b7abab4423118e6f638d2f326749e7a07c6426543bea6da99b623c715b22af71", size = 303280 }, ] [[package]] @@ -2098,9 +2098,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, upload-time = "2025-08-01T20:09:20.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/0c/8ca87d33bea0340a8ed791f36390112aeb29fd3eebfd64b6aef6204a03f0/llama_cloud_services-0.6.54.tar.gz", hash = "sha256:baf65d9bffb68f9dca98ac6e22908b6675b2038b021e657ead1ffc0e43cbd45d", size = 53468 } 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, upload-time = "2025-08-01T20:09:20.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/4e295e3f791b279885a2e584f71e75cbe4ac84e93bba3c36e2668f60a8ac/llama_cloud_services-0.6.54-py3-none-any.whl", hash = "sha256:07f595f7a0ba40c6a1a20543d63024ca7600fe65c4811d1951039977908997be", size = 63874 }, ] [[package]] @@ -2117,9 +2117,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, upload-time = "2026-02-18T19:06:38.527Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/25/d74e56acf693c608bfa2269adfd5a58128973aaa7fd1e77ccf9d5f616f32/llama_index-0.14.15.tar.gz", hash = "sha256:079f65e72af87c72dd8b516aa2dd520b52eb2128722d66ecce1e5148cee357c0", size = 8472 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/02/94/b338e8985313e6e3a5321638f3d7d457310da6cb4ab1298eea3b323cb06c/llama_index-0.14.15-py3-none-any.whl", hash = "sha256:469bf8ff77a445dbf402ed08978a0c8ebf59d40fcd15d289e07e5791e0513cea", size = 7264 }, ] [[package]] @@ -2131,9 +2131,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, upload-time = "2025-09-29T18:03:10.798Z" } +sdist = { url = "https://files.pythonhosted.org/packages/67/84/41e820efffbe327c38228d3b37fe42512a37e0c3ee4ff6bf97a394e9577a/llama_index_cli-0.5.3.tar.gz", hash = "sha256:ebaf39e785efbfa8d50d837f60cb0f95125c04bf73ed1f92092a2a5f506172f8", size = 24821 } 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, upload-time = "2025-09-29T18:03:10.024Z" }, + { url = "https://files.pythonhosted.org/packages/54/81/b7b3778aa8662913760fbbee77578daf4407aeaa677ccbf0125c4cfa2e67/llama_index_cli-0.5.3-py3-none-any.whl", hash = "sha256:7deb1e953e582bd885443881ce8bd6ab2817b594fef00079dce9993c47d990f7", size = 28173 }, ] [[package]] @@ -2170,9 +2170,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, upload-time = "2026-02-18T19:05:48.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/4f/7c714bdf94dd229707b43e7f8cedf3aed0a99938fd46a9ad8a418c199988/llama_index_core-0.14.15.tar.gz", hash = "sha256:3766aeeb95921b3a2af8c2a51d844f75f404215336e1639098e3652db52c68ce", size = 11593505 } 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, upload-time = "2026-02-18T19:05:45.855Z" }, + { url = "https://files.pythonhosted.org/packages/41/9e/262f6465ee4fffa40698b3cc2177e377ce7d945d3bd8b7d9c6b09448625d/llama_index_core-0.14.15-py3-none-any.whl", hash = "sha256:e02b321c10673871a38aaefdc4a93d5ae8ec324cad4408683189e5a1aa1e3d52", size = 11937002 }, ] [[package]] @@ -2183,9 +2183,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, upload-time = "2025-09-08T20:17:44.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/36/90336d054a5061a3f5bc17ac2c18ef63d9d84c55c14d557de484e811ea4d/llama_index_embeddings_openai-0.5.1.tar.gz", hash = "sha256:1c89867a48b0d0daa3d2d44f5e76b394b2b2ef9935932daf921b9e77939ccda8", size = 7020 } 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, upload-time = "2025-09-08T20:17:44.015Z" }, + { 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 }, ] [[package]] @@ -2197,9 +2197,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, upload-time = "2025-09-08T20:29:58.673Z" } +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 } 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, upload-time = "2025-09-08T20:29:57.876Z" }, + { 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 }, ] [[package]] @@ -2210,9 +2210,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, upload-time = "2025-10-13T20:44:48.85Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/b9/a7a74de6d8aacf4be329329495983d78d96b1a6e69b6d9fcf4a233febd4b/llama_index_instrumentation-0.4.2.tar.gz", hash = "sha256:dc4957b64da0922060690e85a6be9698ac08e34e0f69e90b01364ddec4f3de7f", size = 46146 } 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, upload-time = "2025-10-13T20:44:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/40/54/df8063b0441242e250e03d1e31ebde5dffbe24e1af32b025cb1a4544150c/llama_index_instrumentation-0.4.2-py3-none-any.whl", hash = "sha256:b4989500e6454059ab3f3c4a193575d47ab1fadb730c2e8f2b962649ae88b70b", size = 15411 }, ] [[package]] @@ -2223,9 +2223,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, upload-time = "2026-03-01T17:51:14.346Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/d4/cb0ab1aa47d00fc251fad4dfe30093fc964dba9b168a88aaacd0411b5a42/llama_index_llms_openai-0.6.23.tar.gz", hash = "sha256:4208404bbf6c5a18bed2079df9333f4ba931e5b8c6c6a407abe2c47c14ac7b7e", size = 26595 } 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, upload-time = "2026-03-01T17:51:16.452Z" }, + { 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 }, ] [[package]] @@ -2240,9 +2240,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, upload-time = "2025-12-24T16:04:16.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/e5/dccfb495dbc40f50fcfb799db2287ac5dca4a16a3b09bae61a4ccb1788d3/llama_index_readers_file-0.5.6.tar.gz", hash = "sha256:1c08b14facc2dfe933622aaa26dc7d2a7a6023c42d3db896a2c948789edaf1ea", size = 32535 } 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, upload-time = "2025-12-24T16:04:17.307Z" }, + { 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 }, ] [[package]] @@ -2253,9 +2253,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, upload-time = "2025-09-08T20:41:29.201Z" } +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 } 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, upload-time = "2025-09-08T20:41:28.438Z" }, + { 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 }, ] [[package]] @@ -2266,9 +2266,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, upload-time = "2026-01-13T11:46:41.394Z" } +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 } 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, upload-time = "2026-01-13T11:46:39.708Z" }, + { 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 }, ] [[package]] @@ -2279,9 +2279,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, upload-time = "2025-09-08T20:28:54.11Z" } +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 } 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, upload-time = "2025-09-08T20:28:53.406Z" }, + { 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 }, ] [[package]] @@ -2295,9 +2295,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, upload-time = "2026-01-22T15:14:13.007Z" } +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 } 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, upload-time = "2026-01-22T15:14:13.722Z" }, + { 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 }, ] [[package]] @@ -2309,9 +2309,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, upload-time = "2026-01-13T11:13:07.123Z" } +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 } 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, upload-time = "2026-01-13T11:13:08.08Z" }, + { 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 }, ] [[package]] @@ -2322,9 +2322,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, upload-time = "2026-02-20T23:20:19.83Z" } +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 } 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, upload-time = "2026-02-20T23:20:19.057Z" }, + { 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 }, ] [[package]] @@ -2336,9 +2336,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, upload-time = "2026-02-28T00:02:28.904Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/21/0357ededbc3aead4d170ba5a7b33345ee45e07f77423367725ef623c08a6/llama_index_workflows-2.15.0.tar.gz", hash = "sha256:06630ca3887b9bf27da776f10e42811aeedf3534591eb654796319ef898d2d7b", size = 81504 } 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, upload-time = "2026-02-28T00:02:27.074Z" }, + { url = "https://files.pythonhosted.org/packages/b3/43/ae0902d0ec6d75b6724f8db8de7cebaac7b2e7d285d31ac5f58279973d9b/llama_index_workflows-2.15.0-py3-none-any.whl", hash = "sha256:31634e32dcc2ec248b8c4a03d54c98a8e87cefce641edd573bdbd548d89f3c0c", size = 104367 }, ] [[package]] @@ -2348,9 +2348,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, upload-time = "2025-08-01T20:09:23.762Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/f6/93b5d123c480bc8c93e6dc3ea930f4f8df8da27f829bb011100ba3ce23dc/llama_parse-0.6.54.tar.gz", hash = "sha256:c707b31152155c9bae84e316fab790bbc8c85f4d8825ce5ee386ebeb7db258f1", size = 3577 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/05/50/c5ccd2a50daa0a10c7f3f7d4e6992392454198cd8a7d99fcb96cb60d0686/llama_parse-0.6.54-py3-none-any.whl", hash = "sha256:c66c8d51cf6f29a44eaa8595a595de5d2598afc86e5a33a4cebe5fe228036920", size = 4879 }, ] [[package]] @@ -2361,9 +2361,9 @@ dependencies = [ { name = "requests" }, { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/44/18d4158618ebbd76ceb8e43b8deb77f4983e6f1ccff2dffd73d6f3fb1628/llmwhisperer_client-2.6.2.tar.gz", hash = "sha256:ce846af62e7e7337dfcfe2960ec72de2989457b717ab7b9dd4110ee82c002ed0", size = 3268197, upload-time = "2026-02-23T10:52:17.634Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/44/18d4158618ebbd76ceb8e43b8deb77f4983e6f1ccff2dffd73d6f3fb1628/llmwhisperer_client-2.6.2.tar.gz", hash = "sha256:ce846af62e7e7337dfcfe2960ec72de2989457b717ab7b9dd4110ee82c002ed0", size = 3268197 } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/eb/0f9edd21302eddc6020a1b43a78e27f361e8b6b8af7611134a58487f7d8a/llmwhisperer_client-2.6.2-py3-none-any.whl", hash = "sha256:7226344506bc85a663e4d4f8feb763f853ec9bcb6cea9bd9cf170ba135c50cdd", size = 10857, upload-time = "2026-02-23T10:52:16.325Z" }, + { url = "https://files.pythonhosted.org/packages/27/eb/0f9edd21302eddc6020a1b43a78e27f361e8b6b8af7611134a58487f7d8a/llmwhisperer_client-2.6.2-py3-none-any.whl", hash = "sha256:7226344506bc85a663e4d4f8feb763f853ec9bcb6cea9bd9cf170ba135c50cdd", size = 10857 }, ] [[package]] @@ -2373,72 +2373,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, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } 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, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -2448,27 +2448,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, upload-time = "2025-12-22T06:53:53.309Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964 }, ] [[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, upload-time = "2022-01-24T01:14:51.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, ] [[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, upload-time = "2022-08-14T12:40:10.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] [[package]] @@ -2479,10 +2479,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, 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" }, + { 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 }, ] [[package]] @@ -2494,9 +2494,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, upload-time = "2026-02-24T10:58:28.415Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/ec/52e6c9ad90ad7eb3035f5e511123e89d1ecc7617f0c94653264848623c12/msal-1.35.0.tar.gz", hash = "sha256:76ab7513dbdac88d76abdc6a50110f082b7ed3ff1080aca938c53fc88bc75b51", size = 164057 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/56/26/5463e615de18ad8b80d75d14c612ef3c866fcc07c1c52e8eac7948984214/msal-1.35.0-py3-none-any.whl", hash = "sha256:baf268172d2b736e5d409689424d2f321b4142cab231b4b96594c86762e7e01d", size = 120082 }, ] [[package]] @@ -2506,108 +2506,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, upload-time = "2025-03-14T23:51:03.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -2620,54 +2620,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, 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" }, +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 }, ] [[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, upload-time = "2025-04-22T14:54:24.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] [[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, upload-time = "2024-01-21T14:25:19.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, ] [[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, upload-time = "2025-12-08T17:02:39.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504 }, ] [[package]] @@ -2680,70 +2680,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, upload-time = "2026-03-24T06:13:40.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -2757,18 +2757,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, upload-time = "2018-09-07T21:38:18.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/7b/17244b1083e8e604bf154cf9b716aecd6388acd656dd01893d0d244c94d9/oauth2client-4.1.3.tar.gz", hash = "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6", size = 155910 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/95/a9/4f25a14d23f0786b64875b91784607c2277eff25d48f915e39ff0cff505a/oauth2client-4.1.3-py2.py3-none-any.whl", hash = "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac", size = 98206 }, ] [[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, upload-time = "2025-06-19T22:48:08.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, ] [[package]] @@ -2781,9 +2781,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, upload-time = "2025-05-11T10:24:21.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/04/6dce2d581c54a8e55a3b128cf79a93821a68a62bb9a956e65476c5bb247e/office365_rest_python_client-2.6.2.tar.gz", hash = "sha256:ce27f5a1c0cc3ff97041ccd9b386145692be4c64739f243f7d6ac3edbe0a3c46", size = 659460 } 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, upload-time = "2025-05-11T10:24:18.926Z" }, + { 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 }, ] [[package]] @@ -2800,9 +2800,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, upload-time = "2026-02-24T20:02:07.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122 }, ] [[package]] @@ -2813,9 +2813,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, upload-time = "2025-12-11T13:32:39.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356 }, ] [[package]] @@ -2827,9 +2827,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, upload-time = "2025-12-11T13:36:39.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/77/f0b1f2bcf451ec5bc443d53bc7437577c3fc8444b3eb0d416ac5f7558b7b/opentelemetry_distro-0.60b1.tar.gz", hash = "sha256:8b7326b83a55ff7b17bb92225a86e2736a004f6af7aff00cb5d87b2d8e5bc283", size = 2584 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/24/70/78a86531495040fcad9569d7daa630eca06d27d37c825a8aad448b7c1c5b/opentelemetry_distro-0.60b1-py3-none-any.whl", hash = "sha256:581104a786f5df252f4dfe725e0ae16337a26da902acb92d8b3e7aee29f0c76e", size = 3343 }, ] [[package]] @@ -2840,9 +2840,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, upload-time = "2022-12-09T22:28:43.353Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/bd/abafe13a0d77145270a39de7442d12d71b51a9f9d103d15d636110ae8a21/opentelemetry_exporter_otlp-1.15.0.tar.gz", hash = "sha256:4f7c49751d9720e2e726e13b0bb958ccade4e29122c305d92c033da432c8d2c5", size = 6126 } 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, upload-time = "2022-12-09T22:28:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4956610bd5348977fea8818d488793a46d1359337c0226164f093a17c61c/opentelemetry_exporter_otlp-1.15.0-py3-none-any.whl", hash = "sha256:79f22748b6a54808a0448093dfa189c8490e729f67c134d4c992533d9393b33e", size = 6976 }, ] [[package]] @@ -2857,9 +2857,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, upload-time = "2022-12-09T22:28:44.359Z" } +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 } 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, upload-time = "2022-12-09T22:28:14.623Z" }, + { 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 }, ] [[package]] @@ -2874,9 +2874,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, upload-time = "2022-12-09T22:28:45.366Z" } +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 } 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, upload-time = "2022-12-09T22:28:15.776Z" }, + { 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 }, ] [[package]] @@ -2889,9 +2889,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, upload-time = "2025-12-11T13:36:42.515Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096 }, ] [[package]] @@ -2901,9 +2901,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, upload-time = "2022-12-09T22:28:55.409Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/80/b3b2a98039574e57b6b15982219ae025d55f8c46d50dde258865ce5601b4/opentelemetry_proto-1.15.0.tar.gz", hash = "sha256:9c4008e40ac8cab359daac283fbe7002c5c29c77ea2674ad5626a249e64e0101", size = 35713 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/3a/56/8343d94af8f32594f6b0bd273f72a40e430fb5970a353237af53af5d3031/opentelemetry_proto-1.15.0-py3-none-any.whl", hash = "sha256:044b6d044b4d10530f250856f933442b8753a17f94ae37c207607f733fb9a844", size = 52616 }, ] [[package]] @@ -2915,9 +2915,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, upload-time = "2025-12-11T13:32:49.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565 }, ] [[package]] @@ -2928,9 +2928,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, upload-time = "2025-12-11T13:32:50.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982 }, ] [[package]] @@ -2940,27 +2940,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, upload-time = "2024-08-20T21:02:35.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/c7/12632c03022aa5059ce9b6738397cda682dfda9d9afe7008b8a4f98c6ee5/oracledb-2.4.0.tar.gz", hash = "sha256:bdd61a9d5077448b5f1c58af6a14accc287bf8032846c351a3cdde5cf64fe95b", size = 614809 } 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, 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" }, + { 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 }, ] [[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, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, ] [[package]] @@ -2973,41 +2973,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, 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" }, +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 }, ] [[package]] @@ -3020,18 +3020,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, upload-time = "2026-05-09T18:28:52.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919 }, ] [[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, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 }, ] [[package]] @@ -3042,9 +3042,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, upload-time = "2025-12-30T15:49:13.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909 }, ] [[package]] @@ -3056,9 +3056,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, upload-time = "2026-01-05T08:10:29.072Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045 }, ] [[package]] @@ -3068,78 +3068,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, upload-time = "2025-12-05T01:07:17.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -3153,45 +3153,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, upload-time = "2025-05-21T19:39:01.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/9d/07a7f2136ce04cabd21d69c057dc2915867082b0047e6873e424388d4475/pinecone-7.0.1.tar.gz", hash = "sha256:49ff7b0f5be4a2ddec5aaa709758a9f2df56baa58ad46507d081409e246a81ec", size = 207930 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/81/88/896221e991077d353e61991b759f46d75f3b4298eb5a4aa6534c1371f4b0/pinecone-7.0.1-py3-none-any.whl", hash = "sha256:ce7b0dab3c9f7d81e75b24c13fcbca4a51371e08021faaecaf0cd9a45ca1be6c", size = 516590 }, ] [[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, upload-time = "2024-06-05T01:57:52.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/fb/e8a4063264953ead9e2b24d9b390152c60f042c951c47f4592e9996e57ff/pinecone_plugin_interface-0.0.7.tar.gz", hash = "sha256:b8e6675e41847333aa13923cc44daa3f85676d7157324682dc1640588a982846", size = 3370 } 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, upload-time = "2024-06-05T01:57:50.583Z" }, + { url = "https://files.pythonhosted.org/packages/3b/1d/a21fdfcd6d022cb64cef5c2a29ee6691c6c103c4566b41646b080b7536a5/pinecone_plugin_interface-0.0.7-py3-none-any.whl", hash = "sha256:875857ad9c9fc8bbc074dbe780d187a2afd21f5bfe0f3b08601924a61ef1bba8", size = 6249 }, ] [[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, upload-time = "2026-02-16T03:56:10.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168 }, ] [[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, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] [[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, upload-time = "2018-02-15T19:01:31.097Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567 }, ] [[package]] @@ -3201,18 +3201,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, upload-time = "2025-06-14T13:20:40.03Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 }, ] [[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, upload-time = "2026-01-14T15:26:26.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057 }, ] [[package]] @@ -3222,93 +3222,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, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -3318,109 +3318,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, upload-time = "2026-02-02T17:34:49.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480 }, ] [[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, upload-time = "2025-05-28T14:22:25.153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/01/34c8d2b6354906d728703cb9d546a0e534de479e25f1b581e4094c4a85cc/protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd", size = 380920 } wheels = [ - { 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" }, + { 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 }, ] [[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, upload-time = "2024-01-19T20:47:09.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/c7/6dc0a455d111f68ee43f27793971cf03fe29b6ef972042549db29eec39a2/psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c", size = 503247 } 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, 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" }, + { 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 }, ] [[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, upload-time = "2023-10-03T12:48:55.128Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/07/e720e53bfab016ebcc34241695ccc06a9e3d91ba19b40ca81317afbdc440/psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c", size = 384973 } 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, 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" }, + { 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 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997 }, ] [[package]] @@ -3430,27 +3430,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, upload-time = "2025-03-28T02:41:22.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, ] [[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, upload-time = "2025-06-20T18:49:48.75Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594 }, ] [[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, upload-time = "2026-01-21T14:26:51.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, ] [[package]] @@ -3463,9 +3463,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, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, ] [[package]] @@ -3475,68 +3475,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, 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" }, +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 }, ] [[package]] @@ -3549,9 +3549,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, upload-time = "2023-05-21T02:25:57.217Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/74/b591079fa588351cec61861b85ba26f7deb96f3b445556c100e17db5572b/PyDrive2-1.15.4.tar.gz", hash = "sha256:0c011b74ebc24f3c6ca72820626b77f1dfe0ae88f5740c5a5cf96e83dd79ba99", size = 60514 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/18/f4/d0b40ee1c703304e8cc737e53516f834c0fbad4fe9b27aed7680d9fdf344/PyDrive2-1.15.4-py3-none-any.whl", hash = "sha256:91fe28e5f094a6dfff834495c4aee0041cbef979467ad27cd0d4b1f91afa8869", size = 45011 }, ] [package.optional-dependencies] @@ -3566,27 +3566,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, upload-time = "2025-06-20T18:45:27.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551 }, ] [[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, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] [[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, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 }, ] [package.optional-dependencies] @@ -3607,47 +3607,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, upload-time = "2025-12-02T10:58:25.399Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/13/899185f025802ba80255faa8e45b3f3bf9cb7bab2d4235e12e3322c8e2a4/pymilvus-2.5.18.tar.gz", hash = "sha256:9e517076068e98dac51c018bc0dfe1f651d936154e2e2d9ad6c7b3dab1164e2d", size = 1285482 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c0/9c/a2b50b2b369814003460ca12a3c195fbf11b89bc1a861c2aa737c33ad7f9/pymilvus-2.5.18-py3-none-any.whl", hash = "sha256:1b78badcfa8d62db7d0b29193fc0422e4676873ff1c745a9d75c2c885d7a7e32", size = 244089 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2024-05-21T11:03:43.722Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/ce59b5e5ed4ce8512f879ff1fa5ab699d211ae2495f1adaa5fbba2a1eada/pymysql-1.1.1.tar.gz", hash = "sha256:e127611aaf2b417403c60bf4dc570124aeb4a57f5f37b8e95ae399a42f904cd0", size = 47678 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/0c/94/e4181a1f6286f545507528c78016e00065ea913276888db2262507693ce5/PyMySQL-1.1.1-py3-none-any.whl", hash = "sha256:4de15da4c61dc132f4fb9ab763063e693d521a80fd0e87943b9a453dd4c19d6c", size = 44972 }, ] [[package]] @@ -3657,17 +3657,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, upload-time = "2022-01-07T22:05:41.134Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854 } 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, 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" }, + { 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 }, ] [[package]] @@ -3678,56 +3678,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, upload-time = "2026-05-04T23:06:09.72Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823 }, ] [[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, upload-time = "2026-01-21T03:57:59.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781 }, ] [[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, upload-time = "2026-06-17T15:22:00.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/18/9947cc201af9ccf76720fd3347bf4f70eb882ce3fcf4cb05f7443e4cf871/pypdf-6.13.3.tar.gz", hash = "sha256:f3cb822769725f1bac658c406cfc9460399043f3750c2d3e4650e0a85eacabd7", size = 6484063 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/94/56/2967e621598987905fb8cdfadd8f8de6b5c68c9351f0523c4df8409f28f1/pypdf-6.13.3-py3-none-any.whl", hash = "sha256:c6e3f86afb625791510b02ad5480e94b63970bb957df75d44657c282ecc52224", size = 347288 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -3741,9 +3741,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, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, ] [[package]] @@ -3754,9 +3754,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, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, ] [[package]] @@ -3768,9 +3768,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, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, ] [[package]] @@ -3780,9 +3780,21 @@ 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, upload-time = "2025-09-16T16:37:27.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 }, ] [[package]] @@ -3792,18 +3804,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, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } 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, upload-time = "2024-03-01T18:36:18.57Z" }, + { 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 }, ] [[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, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, ] [[package]] @@ -3813,18 +3825,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, upload-time = "2026-02-06T23:38:06.12Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847 }, ] [[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, upload-time = "2022-06-07T20:16:59.508Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677 } 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, upload-time = "2022-06-07T20:16:57.763Z" }, + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840 }, ] [[package]] @@ -3835,47 +3847,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, upload-time = "2026-02-06T23:42:07Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054 }, ] [[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, 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" }, +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 }, ] [[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, upload-time = "2025-03-25T02:25:00.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, ] [[package]] @@ -3883,61 +3895,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, 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" }, + { 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 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -3953,18 +3965,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, upload-time = "2025-12-12T10:58:30.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/7d/3cd10e26ae97b35cf856ca1dc67576e42414ae39502c51165bb36bb1dff8/qdrant_client-1.16.2.tar.gz", hash = "sha256:ca4ef5f9be7b5eadeec89a085d96d5c723585a391eb8b2be8192919ab63185f0", size = 331112 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186 }, ] [[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, upload-time = "2024-12-06T09:50:41.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502 }, ] [[package]] @@ -3976,97 +3988,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, upload-time = "2025-10-13T15:30:48.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -4079,9 +4091,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, upload-time = "2026-03-25T15:10:41.586Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 }, ] [[package]] @@ -4092,9 +4104,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, upload-time = "2024-03-22T20:32:29.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } 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, upload-time = "2024-03-22T20:32:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, ] [[package]] @@ -4104,9 +4116,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, upload-time = "2023-05-01T04:11:33.229Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } 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, upload-time = "2023-05-01T04:11:28.427Z" }, + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, ] [[package]] @@ -4118,9 +4130,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, upload-time = "2026-02-19T14:38:05.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099 }, ] [[package]] @@ -4131,90 +4143,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, upload-time = "2026-02-19T17:23:12.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -4224,9 +4236,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, upload-time = "2025-04-16T09:51:18.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, ] [[package]] @@ -4238,9 +4250,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, upload-time = "2024-10-21T01:45:49.967Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/65/4b4c868cff76c036d11dc75dd91e5696dbf16ce626514166f35d5f4a930f/s3fs-2024.10.0.tar.gz", hash = "sha256:58b8c3650f8b99dbedf361543da3533aac8707035a104db5d80b094617ad4a3f", size = 75916 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/99/44/bb9ff095ae7b1b6908480f683b6ca6b71c2105d343a5e5cb25334b01f5fa/s3fs-2024.10.0-py3-none-any.whl", hash = "sha256:7a2025d60d5b1a6025726b3a5e292a8e5aa713abc3b16fd1f81735181f7bb282", size = 29855 }, ] [package.optional-dependencies] @@ -4255,27 +4267,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, upload-time = "2024-11-20T21:06:05.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175 }, ] [[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, upload-time = "2026-02-08T15:08:40.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468 }, ] [[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, upload-time = "2023-10-24T04:13:40.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] [[package]] @@ -4285,33 +4297,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, upload-time = "2024-10-10T22:39:31.412Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842 }, ] [[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, upload-time = "2017-08-10T19:52:45.903Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/98/a8b5c919bee1152a9a1afd82014431f8db5882699754de50d1b3aba4d136/singleton-decorator-1.0.0.tar.gz", hash = "sha256:1a90ad8a8a738be591c9c167fdd677c5d4a43d1bc6b1c128227be1c5e03bee07", size = 2791 } [[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, upload-time = "2024-12-04T17:35:28.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] [[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, upload-time = "2024-02-25T23:20:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] [[package]] @@ -4337,23 +4349,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, 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" }, +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 }, ] [package.optional-dependencies] @@ -4366,18 +4378,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, upload-time = "2021-05-16T22:03:42.897Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, ] [[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, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016 }, ] [[package]] @@ -4388,42 +4400,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, 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" }, +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 }, ] [package.optional-dependencies] @@ -4439,27 +4451,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, upload-time = "2022-01-25T21:32:16.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/6f/ef25bbc1aefeb9c905d527f1d3cd3f41f22f40566d33001b8bb14ae0cdaf/stone-3.3.1.tar.gz", hash = "sha256:4ef0397512f609757975f7ec09b35639d72ba7e3e17ce4ddf399578346b4cb50", size = 190888 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5c/92/d0c83f63d3518e5f0b8a311937c31347349ec9a47b209ddc17f7566f58fc/stone-3.3.1-py3-none-any.whl", hash = "sha256:e15866fad249c11a963cce3bdbed37758f2e88c8ff4898616bc0caeb1e216047", size = 162257 }, ] [[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, upload-time = "2023-07-20T14:30:36.29Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/20/3d419008265346452d09e5dadfd5d045b64b40d8fc31af40588e6c76997a/striprtf-0.0.26.tar.gz", hash = "sha256:fdb2bba7ac440072d1c41eab50d8d74ae88f60a8b6575c6e2c7805dc462093aa", size = 6258 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a3/cf/0fea4f4ba3fc2772ac2419278aa9f6964124d4302117d61bc055758e000c/striprtf-0.0.26-py3-none-any.whl", hash = "sha256:8c8f9d32083cdc2e8bfb149455aa1cc5a4e0a035893bedc75db8b73becb3a1bb", size = 6914 }, ] [[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, upload-time = "2026-02-07T10:45:33.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 }, ] [[package]] @@ -4470,52 +4482,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, 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" }, +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 }, ] [[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, upload-time = "2026-03-15T18:48:01.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/59/8a8cb2331e2602b53e4dc06960f57d1387a2b18e7efd24e5f9cb60ea4925/tinytag-2.2.1.tar.gz", hash = "sha256:e6d06610ebe7cd66fd07be2d3b9495914ab32654a5e47657bb8cd44c2484523c", size = 38214 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ce/34/d50e338631baaf65ec5396e70085e5de0b52b24b28db1ffbc1c6e82190dc/tinytag-2.2.1-py3-none-any.whl", hash = "sha256:ed8b1e6d25367937e3321e054f4974f9abfde1a3e0a538824c87da377130c2b6", size = 32927 }, ] [[package]] @@ -4525,32 +4537,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, upload-time = "2026-01-05T10:45:15.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115 } 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, 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" }, + { 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 }, ] [[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, upload-time = "2026-01-13T01:14:53.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310 }, ] [[package]] @@ -4560,9 +4572,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, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374 }, ] [[package]] @@ -4575,18 +4587,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, upload-time = "2026-02-13T10:04:30.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813 }, ] [[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, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] [[package]] @@ -4597,9 +4609,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, upload-time = "2023-05-24T20:25:47.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, ] [[package]] @@ -4609,73 +4621,73 @@ 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, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, ] [[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, upload-time = "2025-12-13T17:45:35.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -4958,6 +4970,7 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "pytest-timeout" }, { name = "responses" }, ] @@ -5008,6 +5021,7 @@ test = [ { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "pytest-mock", specifier = ">=3.11.0" }, + { name = "pytest-timeout", specifier = ">=2.3.1" }, { name = "responses", specifier = ">=0.23.0" }, ] @@ -5038,45 +5052,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, upload-time = "2025-06-02T15:12:06.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488 }, ] [[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, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, ] [[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, upload-time = "2025-05-01T05:42:06.7Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/66/a435d9ae49850b2f071f7ebd8119dd4e84872b01630d6736761e6e7fd847/validators-0.35.0.tar.gz", hash = "sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a", size = 73399 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712 }, ] [[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, upload-time = "2023-11-05T08:46:53.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636 }, ] [[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, upload-time = "2026-02-06T19:19:40.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189 }, ] [[package]] @@ -5092,67 +5106,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, upload-time = "2026-02-25T13:39:56.125Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0e/450732a620dec30e5c13d0e5ba6ea81cd2b168ffb5a1e6cfa034da2fd988/weaviate_client-4.20.1.tar.gz", hash = "sha256:7d3c4835292b17d54757c3f62921e87975c01f26869db787250cebef2a17bd80", size = 807802 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/de/3a/a2c74bfbdc5f8acbab8025205520448dcece82942206a9861ec48ccad03f/weaviate_client-4.20.1-py3-none-any.whl", hash = "sha256:6ca36bb8752c39589bfc856c1232cbcb627f69141e7a741b592e2516888f59d8", size = 618735 }, ] [[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, upload-time = "2025-10-07T21:16:36.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 }, ] [[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, 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" }, +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 }, ] [[package]] @@ -5162,9 +5176,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, upload-time = "2025-11-20T18:18:01.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405 }, ] [[package]] @@ -5176,115 +5190,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, 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" }, +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 }, ] [[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, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, ] [[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, upload-time = "2025-08-29T01:03:36.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/f2/690a35762cf8366ce6f3b644805de970bd6a897ca44ce74184c7b2bc94e7/zipstream_ng-1.9.0.tar.gz", hash = "sha256:a0d94030822d137efbf80dfdc680603c42f804696f41147bb3db895df667daea", size = 37963 } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/de/62/c2da1c495291a52e561257d017585e08906d288035d025ccf636f6b9a266/zipstream_ng-1.9.0-py3-none-any.whl", hash = "sha256:31dc2cf617abdbf28d44f2e08c0d14c8eee2ea0ec26507a7e4d5d5f97c564b7a", size = 24852 }, ] From f8402d8c6d143e00545c22ce134f32ceab821aaf Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:20:50 +0530 Subject: [PATCH 55/65] =?UTF-8?q?UN-3669=20[FIX]=20PG=20queue=20=E2=80=94?= =?UTF-8?q?=20barrier=20abort=20+=20poison-drop=20leave=20a=20recoverable?= =?UTF-8?q?=20terminal=20handle=20(UN-3670)=20(#2140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3669 [FIX] PG queue — barrier abort + poison-drop leave a recoverable terminal handle (UN-3670) Two PG-path failures could strand an execution EXECUTING forever with no handle the reaper can find: - UN-3669: the in-body barrier abort deleted the pg_barrier_state row (and the per-batch dedup markers) without marking the execution terminal, so a failed batch left the execution non-terminal forever, and — because the markers were cleared — a redelivered message re-ran the WHOLE batch (real LLM spend + destination writes) against a torn-down barrier. - UN-3670: a poison-dropped message (read_ct > max_attempts) with no reply channel (pipeline header / aggregating callback) was deleted with only a log line. The barrier row is deleted right after the callback is enqueued, so a ~3-minute backend/internal-API outage while callbacks are in flight permanently stranded every affected execution. Both paths now mark the execution ERROR (+cascade non-terminal files) BEFORE erasing their recovery handle: - Barrier abort marks ERROR first; only on a confirmed mark is the row torn down (keeping dedup markers so redelivery is skipped by claim_batch, not re-run wholesale). If the mark can't be confirmed (backend down / no org), the barrier row is left intact for the reaper. - Poison-drop marks ERROR for a message carrying execution_id; if the mark can't be confirmed it re-parks the message with a long vt (bounded budget) instead of deleting into a void, so the drop never races a dead backend. Messages with reply_key/on_error keep existing behavior. Shared helper mark_execution_error (queue_backend/pg_queue/recovery.py) and a new PgQueueClient.set_vt re-park primitive. PG-transport-only — the Celery path is untouched (barrier_pg_abort defaults preserve_dedup_markers=False; Celery-errback and reply-channel paths proven unchanged by tests). Tests: recovery helper, barrier mark-first + preserve-markers + unconfirmed- strand, consumer poison mark/re-park/budget/reply-channel, _pipeline_identity extraction, and client set_vt. Unit lane 1128 passed; touched integration lane green against local Postgres. Co-Authored-By: Claude Fable 5 * UN-3669 [FIX] Address PR review — 3-state poison outcome, shared recovery accessor, SonarCloud nits PR Review Toolkit (6 agents) + SonarCloud findings on PR #2140: - Poison-drop now returns a 3-state outcome instead of a bool: CONFIRMED (marked → drop), UNMARKABLE (no org — permanent, so drop NOW instead of burning the whole re-park budget), TRANSIENT (backend down / client build failed → re-park). Fixes the no-org message wastefully re-parking ~25 min and the "dropping"/"re-parking" log/behavior drift. (MEDIUM) - Corrected the budget-exhaustion log: it no longer claims guaranteed reaper recovery — an aggregating-callback message has no barrier row left, so it needs manual replay from the logged payload (the bound is a backstop, not a reaper net for the callback case). (MEDIUM) - set_vt return value is now checked before logging a re-park, so a message already gone before the re-park no longer logs a re-park that didn't happen. (LOW) - Widened the "re-park" comment to cover all unconfirmed causes (backend down, no org, client-build failure), not just a down backend. (LOW) - Extracted the callback-descriptor identity dig into one shared accessor, barrier.callback_recovery_identity, used by BOTH recovery sites (consumer poison-drop + pg_barrier abort), so a producer-side kwarg rename can't silently regress the safety net in two places. (MEDIUM, type-design) - recovery.mark_execution_error logs a *successful* recovery at WARNING, not ERROR (the underlying failure is already logged ERROR by the caller). (LOW) - SonarCloud: extracted the poison branch into _drop_poison_message to bring _handle's cognitive complexity under 15; hoisted the shared mock/client out of two pytest.raises blocks (one-throwing-call rule). Tests: added the three flagged coverage gaps — re-park budget boundary (read_ct == max_attempts + budget must re-park, guards >/>=), no-org poison (drops now, mark not attempted), and the consumer's own client-build-failure re-park (lazy build path). Unit lane 1131 passed; touched integration lane green against local Postgres. Co-Authored-By: Claude Fable 5 * UN-3669 [FIX] Address Greptile + SonarCloud — defensive callback_descriptor access, one-throwing-call test - Greptile P2 (pg_barrier.py): _mark_execution_error_on_abort runs inside run_batch_with_barrier's except block, so a missing callback_descriptor would raise a KeyError that masks the original batch exception before the re-raise. Use barrier_context.get("callback_descriptor") or {} (BarrierContext always stamps it today; this hardens any future/legacy dispatch path). - SonarCloud (test_pg_queue_consumer.py L387): hoist the shared MagicMock client out of test_rejects_backoff_max_below_poll_interval's pytest.raises block so only the invocation under test can throw. Greptile P2 on consumer._get_api_client (cache build failures) intentionally NOT taken — it mirrors the reaper's retry-on-failure builder, the cost is negligible (poison-drop-only, bounded by the re-park budget; the import is already cached), and caching a permanent-failure sentinel would block in-process recovery if the build failure were ever transient. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- workers/queue_backend/barrier.py | 18 ++ workers/queue_backend/pg_barrier.py | 120 +++++++++- workers/queue_backend/pg_queue/client.py | 22 ++ workers/queue_backend/pg_queue/consumer.py | 221 ++++++++++++++++-- workers/queue_backend/pg_queue/recovery.py | 84 +++++++ workers/tests/test_pg_barrier.py | 168 ++++++++++++-- workers/tests/test_pg_queue_client.py | 20 ++ workers/tests/test_pg_queue_consumer.py | 246 ++++++++++++++++++++- workers/tests/test_pg_recovery.py | 63 ++++++ 9 files changed, 910 insertions(+), 52 deletions(-) create mode 100644 workers/queue_backend/pg_queue/recovery.py create mode 100644 workers/tests/test_pg_recovery.py diff --git a/workers/queue_backend/barrier.py b/workers/queue_backend/barrier.py index 9e5c47dc1e..4695765de2 100644 --- a/workers/queue_backend/barrier.py +++ b/workers/queue_backend/barrier.py @@ -161,6 +161,24 @@ class BarrierContext(TypedDict): callback_descriptor: CallbackDescriptor +def callback_recovery_identity( + callback_descriptor: CallbackDescriptor, +) -> tuple[str | None, str | None]: + """``(execution_id, organization_id)`` from a callback descriptor's kwargs. + + The barrier's recovery net (mark the execution ERROR when a batch strands) + needs the execution's identity, which the fan-out stamps into the callback's + ``kwargs`` (they are the aggregating callback's own arguments). Both recovery + sites — the in-body abort in :mod:`queue_backend.pg_barrier` and the consumer + poison-drop in :mod:`queue_backend.pg_queue.consumer` — read it through this + one accessor, so a producer-side kwarg rename is caught here rather than + silently regressing the safety net in two places. Either field may be ``None`` + (a malformed/legacy descriptor); the callers handle a missing org. + """ + kwargs = callback_descriptor.get("kwargs") or {} + return kwargs.get("execution_id"), kwargs.get("organization_id") + + class Barrier(Protocol): """Fan-out-then-callback primitive. diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 531cc7d28c..6b3fdb69a3 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -82,7 +82,12 @@ is_pg_transport, ) -from .barrier import BarrierContext, CallbackDescriptor, barrier_ttl_seconds +from .barrier import ( + BarrierContext, + CallbackDescriptor, + barrier_ttl_seconds, + callback_recovery_identity, +) from .decorator import worker_task from .fairness import ( DEFAULT_PRIORITY, @@ -969,6 +974,7 @@ def barrier_pg_abort( traceback: Any = None, *, execution_id: str, + preserve_dedup_markers: bool = False, ) -> dict[str, Any]: """``link_error`` callback: a header task failed → tear down barrier state. @@ -982,10 +988,20 @@ def barrier_pg_abort( in-flight successful decrement then finds no row and abandons (no partial fire). + ``preserve_dedup_markers`` keeps the per-batch ``pg_batch_dedup`` markers in + place (default ``False`` — the Celery ``link_error`` path writes no markers, + so clearing them is a harmless no-op there). The in-body PG path passes + ``True``: the failed message can still redeliver, and a surviving marker makes + ``claim_batch`` return ``False`` on redelivery → the batch is skipped, not + re-run wholesale (real LLM spend). The markers are reclaimed later by the + barrier re-arm (same execution_id) or the dedup-orphan sweep; they can never + re-run anything on their own. + The ``request``/``exc``/``traceback`` defaults keep the old-style ``errback(task_id)`` invocation (mixed-version deploy / ``bind=True``) from raising a ``TypeError``. This task does NOT drive workflow terminal status — - the outer orchestrators own that. + the outer orchestrators own that (the in-body PG path marks the execution + ERROR before calling here; see :func:`run_batch_with_barrier`). """ del request, exc, traceback # logged by the outer task; unused here with _cursor() as cur: @@ -1002,29 +1018,39 @@ def barrier_pg_abort( # Another failure already aborted this execution (or it's already gone). return {"status": "already_aborted", "execution_id": execution_id} - # We won the abort: reclaim the per-batch dedup markers too (best-effort — - # the barrier is already torn down). A leftover marker would otherwise linger - # until the future dedup-orphan sweep; it can never re-run anything. - with contextlib.suppress(Exception): - clear_execution_batches(execution_id) + if not preserve_dedup_markers: + # We won the abort: reclaim the per-batch dedup markers too (best-effort — + # the barrier is already torn down). A leftover marker would otherwise + # linger until the future dedup-orphan sweep; it can never re-run anything. + with contextlib.suppress(Exception): + clear_execution_batches(execution_id) logger.error( f"[exec:{execution_id}] PgBarrier aborted — a header task failed; " - f"barrier state cleaned up (aggregating callback will not fire)." + f"barrier state cleaned up (aggregating callback will not fire; " + f"dedup markers {'preserved' if preserve_dedup_markers else 'cleared'})." ) return {"status": "aborted", "execution_id": execution_id} -def _abort_barrier_in_body(execution_id: str, *, reason: str) -> None: +def _abort_barrier_in_body( + execution_id: str, *, reason: str, preserve_dedup_markers: bool = False +) -> None: """Tear the barrier down from the in-body PG path (no ``.link_error`` here). Best-effort: a batch failure and a DB failure are correlated, so the abort itself can fail — that's logged (not swallowed silently) so a stuck barrier isn't masked by a misleading "torn down" message. A failed teardown bottoms out at the barrier's ``expires_at`` and is reclaimed by the reaper. + + ``preserve_dedup_markers`` forwards to :func:`barrier_pg_abort` — the in-body + path keeps the per-batch markers so a redelivered batch is skipped by + ``claim_batch`` rather than re-run wholesale. """ try: - barrier_pg_abort(execution_id=execution_id) + barrier_pg_abort( + execution_id=execution_id, preserve_dedup_markers=preserve_dedup_markers + ) logger.error( f"[exec:{execution_id}] {reason} — barrier teardown attempted in-body " f"(no .link_error on the PG path); aggregating callback won't fire." @@ -1037,6 +1063,59 @@ def _abort_barrier_in_body(execution_id: str, *, reason: str) -> None: ) +def _mark_execution_error_on_abort( + barrier_context: BarrierContext, *, reason: str +) -> bool: + """Best-effort: mark the execution ERROR (+cascade files) on an in-body PG + batch failure, so a failed batch reaches a terminal state instead of being + stranded ``EXECUTING`` forever with no handle the reaper can find. + + Returns ``True`` iff the execution is now terminal (the mark was confirmed) — + only then is it safe for the caller to tear the barrier row down. On failure + (backend unreachable, or no ``organization_id`` to scope the org API) returns + ``False``: the caller must leave the barrier row so the reaper recovers it at + expiry, rather than erasing the only recovery handle. + + PG path only — ``run_batch_with_barrier`` is the fire-and-forget substrate; + the Celery path never reaches here (its outer orchestrator owns terminal + status). The org and execution id come off the barrier's callback kwargs, the + same values the enqueue stamped onto ``pg_barrier_state``. + """ + execution_id = str(barrier_context["execution_id"]) + # .get(): this runs inside run_batch_with_barrier's except block, so a missing + # callback_descriptor (a future/legacy dispatch path) must not raise a KeyError + # that would mask the original batch exception before the re-raise. + _, org = callback_recovery_identity(barrier_context.get("callback_descriptor") or {}) + organization_id = str(org or "") + if not organization_id: + logger.error( + f"[exec:{execution_id}] {reason} but the barrier carries no " + f"organization_id — cannot mark it ERROR via the org-scoped API; " + f"leaving the barrier row for the reaper (not erasing the handle)." + ) + return False + # Lazy import: keep this module free of the HTTP/env client at import time + # (mirrors the reaper) and avoid an import cycle via shared.api. + from shared.api import InternalAPIClient + + from .pg_queue.recovery import mark_execution_error + + try: + api_client = InternalAPIClient() + except Exception: + logger.exception( + f"[exec:{execution_id}] {reason} and building the internal API client " + f"to mark it ERROR failed — leaving the barrier row for the reaper." + ) + return False + return mark_execution_error( + api_client, + execution_id, + organization_id, + error_message=f"[pg-barrier-abort] {reason}.", + ) + + # A batch whose execution is already terminal returns its result with this # marker set. run_batch_with_barrier bypasses the barrier decrement for it: the # reaper has by definition already torn the barrier down, so decrementing would @@ -1129,6 +1208,25 @@ def run_batch_with_barrier( callback_descriptor=barrier_context["callback_descriptor"], ) except Exception: - _abort_barrier_in_body(execution_id, reason=f"batch {batch_index} failed") + reason = f"batch {batch_index} failed" + # Mark the execution ERROR FIRST (+cascade files) so it reaches a terminal + # state. Only if that's confirmed do we tear the barrier row down — the + # row is the reaper's only recovery handle, so we must not delete it while + # the execution is still non-terminal. On a confirmed mark, keep the dedup + # markers so this message's redelivery is skipped by claim_batch (belt-and- + # braces with the terminal-execution guard) rather than re-run wholesale. + if _mark_execution_error_on_abort(barrier_context, reason=reason): + _abort_barrier_in_body( + execution_id, reason=reason, preserve_dedup_markers=True + ) + else: + # Mark unconfirmed (backend down / no org): leave the barrier row so + # the reaper marks it ERROR and reclaims it at expiry. Do NOT erase the + # handle — that's the strand this ticket fixes. + logger.error( + f"[exec:{execution_id}] {reason} and could not confirm the ERROR " + f"mark — leaving the barrier row intact for the reaper (barrier " + f"hangs to expiry rather than stranding non-terminal)." + ) raise return result diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py index a3f153a87d..e7dccf8099 100644 --- a/workers/queue_backend/pg_queue/client.py +++ b/workers/queue_backend/pg_queue/client.py @@ -362,6 +362,28 @@ def read( QueueMessage(msg_id=int(r[0]), message=r[1], read_ct=int(r[2])) for r in rows ] + def set_vt(self, msg_id: int, vt_seconds: int) -> bool: + """Re-park a claimed message: hide it for another ``vt_seconds``. + + Returns ``True`` if a row was updated (``False`` = the row is already gone, + e.g. its vt expired and another reader deleted it). Does NOT touch + ``read_ct`` — the increment happens on the next :meth:`read` when the row + reappears, so a re-park loop is naturally bounded by ``read_ct`` climbing. + + Used by the consumer to defer a poison message whose terminal-ERROR mark + could not be confirmed (backend down), so the drop never races a dead + backend and the payload isn't discarded into a void. + """ + if vt_seconds <= 0: + raise ValueError(f"vt_seconds must be positive, got {vt_seconds}") + with self._cursor() as cur: + cur.execute( + f"UPDATE {qualified('pg_queue_message')} " + "SET vt = now() + make_interval(secs => %s) WHERE msg_id = %s", + (vt_seconds, msg_id), + ) + return cur.rowcount == 1 + def delete(self, msg_id: int) -> bool: """Ack a processed message. Returns ``True`` if a row was removed. diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index 5b75fe74ce..a00c556079 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -26,12 +26,14 @@ import signal import time from collections.abc import Callable +from enum import Enum from typing import TYPE_CHECKING, TypeVar from celery import current_app from unstract.core.data_models import ContinuationSpec, TaskPayload +from ..barrier import callback_recovery_identity from ..fairness import FAIRNESS_HEADER_NAME from .client import PgQueueClient from .liveness import LivenessServer as _BaseLivenessServer @@ -40,6 +42,7 @@ if TYPE_CHECKING: from celery import Celery + from shared.api import InternalAPIClient from .client import QueueMessage @@ -59,6 +62,14 @@ # A task claimed more than this many times keeps failing — drop it (poison) # rather than redeliver forever. _DEFAULT_MAX_ATTEMPTS = 5 +# When a poison drop's terminal-ERROR mark can't be confirmed (backend down), the +# message is re-parked this long instead of deleted into a void — so the drop +# never races a dead backend. Comfortably above a brief backend restart. +_DEFAULT_POISON_REPARK_VT_SECONDS = 300 +# ...and give up (delete despite an unconfirmed mark, leaving the reaper as the +# last net) after this many extra reads past max_attempts, so a permanently +# unmarkable pipeline message can't re-park forever. +_DEFAULT_POISON_REPARK_BUDGET = 5 # Liveness: a poll loop that hasn't cycled in this many seconds is reported # unhealthy. The heartbeat is stamped at the top of each poll_once and frozen # during task execution, so this threshold doubles as an UPPER BOUND on a single @@ -83,6 +94,49 @@ def _json_safe(value: object) -> object: return json.loads(json.dumps(value, default=str)) +class _PoisonMarkOutcome(Enum): + """Result of trying to mark a poison-dropped execution ERROR (drives whether + the consumer drops the message now or re-parks it). + """ + + CONFIRMED = "confirmed" # marked terminal → safe to drop the message + UNMARKABLE = "unmarkable" # permanent (no org) → drop now; re-park can't help + TRANSIENT = "transient" # backend down / client build failed → re-park + + +def _pipeline_identity(payload: TaskPayload) -> tuple[str | None, str]: + """Best-effort ``(execution_id, organization_id)`` from a fire-and-forget + payload, for marking the execution ERROR on a poison drop. + + The identity lives in one of three places, tried in order: directly on a + callback payload's ``kwargs`` (the aggregating-callback case — the sharpest + strand); on the injected ``_barrier_context``'s callback descriptor (a + pipeline header); or the org on the ``fairness`` payload. The callback- + descriptor dig goes through :func:`callback_recovery_identity` so it can't + drift from the barrier abort site. Returns ``(None, "")`` when the payload + isn't a pipeline message — nothing to mark. ``organization_id`` may be ``""`` + even with an ``execution_id`` (the caller drops, since the status API is + org-scoped and re-parking can't conjure an org). + """ + kwargs = payload.get("kwargs") or {} + barrier_ctx = kwargs.get("_barrier_context") or {} + cb_execution_id, cb_org = callback_recovery_identity( + barrier_ctx.get("callback_descriptor") or {} + ) + execution_id = ( + kwargs.get("execution_id") or barrier_ctx.get("execution_id") or cb_execution_id + ) + organization_id = ( + kwargs.get("organization_id") + or cb_org + or (payload.get("fairness") or {}).get("org_id") + ) + return ( + str(execution_id) if execution_id else None, + str(organization_id or ""), + ) + + class PgQueueConsumer: """Polls one PG queue, runs each claimed task in-process, acks on success.""" @@ -92,11 +146,14 @@ def __init__( *, client: PgQueueClient | None = None, app: Celery | None = None, + api_client: InternalAPIClient | None = None, batch_size: int = _DEFAULT_BATCH, vt_seconds: int = _DEFAULT_VT_SECONDS, poll_interval: float = _DEFAULT_POLL_INTERVAL, backoff_max: float = _DEFAULT_BACKOFF_MAX, max_attempts: int = _DEFAULT_MAX_ATTEMPTS, + poison_repark_vt_seconds: int = _DEFAULT_POISON_REPARK_VT_SECONDS, + poison_repark_budget: int = _DEFAULT_POISON_REPARK_BUDGET, ) -> None: # Validate at construction so a misconfigured consumer fails here # rather than batch-after-batch once the loop starts. @@ -106,6 +163,8 @@ def __init__( ("poll_interval", poll_interval), ("backoff_max", backoff_max), ("max_attempts", max_attempts), + ("poison_repark_vt_seconds", poison_repark_vt_seconds), + ("poison_repark_budget", poison_repark_budget), ): if value <= 0: raise ValueError(f"{name} must be positive, got {value!r}") @@ -128,11 +187,17 @@ def __init__( self.queue_names = list(dict.fromkeys(queue_names)) self._client = client if client is not None else PgQueueClient() self._app = app if app is not None else current_app + # Lazily built the first time a poison drop needs to mark an execution + # ERROR (fire-and-forget consumers with no poison never build it); an + # injected client short-circuits the build (tests / DI). + self._api_client = api_client self.batch_size = batch_size self.vt_seconds = vt_seconds self.poll_interval = poll_interval self.backoff_max = backoff_max self.max_attempts = max_attempts + self._poison_repark_vt_seconds = poison_repark_vt_seconds + self._poison_repark_budget = poison_repark_budget self._running = False # Request-reply (executor RPC) result store — lazily created the first # time a message carries a ``reply_key``; fire-and-forget consumers @@ -206,20 +271,7 @@ def _handle(self, message: QueueMessage) -> None: # it (with the payload, so it's recoverable from logs) instead of # redelivering on every vt expiry forever. if message.read_ct > self.max_attempts: - logger.error( - "PG-queue consumer: task %r (msg_id=%s) exceeded max_attempts=%s " - "(read_ct=%s) — dropping poison message: %r", - task_name, - message.msg_id, - self.max_attempts, - message.read_ct, - payload, - ) - self._fail_dispatch( - payload, - error=f"task {task_name} exceeded max_attempts={self.max_attempts}", - ) - self._client.delete(message.msg_id) + self._drop_poison_message(message, payload, task_name) return task = self._app.tasks.get(task_name) @@ -443,6 +495,147 @@ def _fail_dispatch(self, payload: TaskPayload, *, error: str) -> None: error=error, ) + def _drop_poison_message( + self, message: QueueMessage, payload: TaskPayload, task_name: str | None + ) -> None: + """Handle a message that exceeded ``max_attempts`` (poison). + + A message with a failure channel (``reply_key`` / ``on_error``) surfaces + the failure there and is dropped — existing behavior. A pipeline header / + barrier callback has neither but carries an ``execution_id``: a bare delete + would silently strand the execution EXECUTING-forever (the barrier row is + already gone → the reaper has no handle), so mark it ERROR first. The mark + has three outcomes (see :class:`_PoisonMarkOutcome`): confirmed → drop; + permanently unmarkable (no org) → drop now, since re-parking can never + help; transient (backend down / client build failed) → re-park with a long + vt rather than delete into a void, bounded by ``poison_repark_budget``. + """ + execution_id, organization_id = _pipeline_identity(payload) + logger.error( + "PG-queue consumer: task %r (msg_id=%s) exceeded max_attempts=%s " + "(read_ct=%s, execution_id=%s) — poison; full payload: %r", + task_name, + message.msg_id, + self.max_attempts, + message.read_ct, + execution_id, + payload, + ) + # Failure channel (request-reply / on_error) → surface there, then drop. + if payload.get("reply_key") or payload.get("on_error"): + self._fail_dispatch( + payload, + error=f"task {task_name} exceeded max_attempts={self.max_attempts}", + ) + self._client.delete(message.msg_id) + return + # No failure channel and not a pipeline message → nothing to mark; drop. + if execution_id is None: + self._client.delete(message.msg_id) + return + # Pipeline strand: mark ERROR so the failure is visible and re-runnable. + outcome = self._mark_poison_execution_error( + execution_id, organization_id, task_name + ) + if outcome is _PoisonMarkOutcome.CONFIRMED: + self._client.delete(message.msg_id) # terminal → safe to drop + return + if outcome is _PoisonMarkOutcome.UNMARKABLE: + # Permanent (no org): re-parking can never change the outcome, so drop + # now rather than burn the whole budget. The full payload was logged + # above for manual recovery. + self._client.delete(message.msg_id) + return + # TRANSIENT (backend down / client build failed): re-park rather than + # delete into a void, bounded so a permanently-stuck message can't re-park + # forever. NOTE: the reaper only recovers executions that still have a + # pg_barrier_state row; an aggregating-callback message is enqueued AFTER + # that row is deleted, so on budget exhaustion here it has no reaper handle + # and needs manual replay from the logged payload — the bound is a + # backstop, not a guaranteed reaper recovery for the callback case. + if message.read_ct > self.max_attempts + self._poison_repark_budget: + logger.error( + "PG-queue consumer: exhausted poison re-park budget for execution " + "%s (msg_id=%s, read_ct=%s) — dropping with an unconfirmed ERROR " + "mark. If this was an aggregating callback its barrier row is " + "already gone, so the reaper cannot recover it: manual replay from " + "the full payload logged above may be required.", + execution_id, + message.msg_id, + message.read_ct, + ) + self._client.delete(message.msg_id) + return + if not self._client.set_vt(message.msg_id, self._poison_repark_vt_seconds): + # Row already gone (vt expired and another reader deleted it) — nothing + # to re-park; don't log a re-park that didn't happen. + logger.info( + "PG-queue consumer: poison message %s already gone before re-park.", + message.msg_id, + ) + return + logger.warning( + "PG-queue consumer: could not confirm ERROR mark for poison execution " + "%s (msg_id=%s, read_ct=%s) — re-parked for %ss instead of dropping.", + execution_id, + message.msg_id, + message.read_ct, + self._poison_repark_vt_seconds, + ) + + def _get_api_client(self) -> InternalAPIClient: + # Lazy import + build (mirrors the reaper): keeps a fire-and-forget + # consumer that never poisons free of the HTTP/env client, and avoids a + # module-load import cycle via shared.api. + if self._api_client is None: + from shared.api import InternalAPIClient + + self._api_client = InternalAPIClient() + return self._api_client + + def _mark_poison_execution_error( + self, execution_id: str, organization_id: str, task_name: str | None + ) -> _PoisonMarkOutcome: + """Best-effort: mark a poison-dropped pipeline execution ERROR (+cascade). + + Returns a :class:`_PoisonMarkOutcome`: ``CONFIRMED`` when the backend + confirmed the mark (safe to drop); ``UNMARKABLE`` when it can never be + marked (no org to scope the status API — re-parking is pointless, drop + now); ``TRANSIENT`` when the mark failed for a possibly-recoverable reason + (backend down, or the API client couldn't be built) so the caller re-parks + rather than dropping into a void. + """ + if not organization_id: + logger.error( + "PG-queue consumer: poison message for execution %s carries no " + "organization_id — cannot mark it ERROR via the org-scoped API; " + "dropping now (re-parking cannot help). Manual recovery from the " + "logged payload may be required.", + execution_id, + ) + return _PoisonMarkOutcome.UNMARKABLE + try: + api_client = self._get_api_client() + except Exception: + logger.exception( + "PG-queue consumer: could not build the internal API client to " + "mark poison execution %s ERROR — will re-park", + execution_id, + ) + return _PoisonMarkOutcome.TRANSIENT + from .recovery import mark_execution_error + + confirmed = mark_execution_error( + api_client, + execution_id, + organization_id, + error_message=( + f"[pg-poison-drop] task {task_name} exceeded " + f"max_attempts={self.max_attempts}." + ), + ) + return _PoisonMarkOutcome.CONFIRMED if confirmed else _PoisonMarkOutcome.TRANSIENT + def _fail_reply(self, reply_key: str | None, error: str) -> None: """Best-effort failure reply for a request-reply message that can't run or whose run raised (drop / poison / unknown-task / exception). diff --git a/workers/queue_backend/pg_queue/recovery.py b/workers/queue_backend/pg_queue/recovery.py new file mode 100644 index 0000000000..ad31847cf5 --- /dev/null +++ b/workers/queue_backend/pg_queue/recovery.py @@ -0,0 +1,84 @@ +"""Best-effort terminal-ERROR marking for stranded PG-path executions. + +Any PG failure that tears down its own recovery handle — the barrier abort +deletes ``pg_barrier_state``; the consumer poison-drop deletes the queue +message — must first make the workflow execution terminal, or the execution is +stranded ``EXECUTING`` forever with nothing left for the reaper to find (the +reaper only recovers executions that still have a barrier row). + +:func:`mark_execution_error` centralises that mark: set the execution ERROR via +the internal API, cascading to its non-terminal file executions in the same +backend transaction (so the execution never goes ERROR while its files stay +EXECUTING). It is deliberately best-effort and returns a bool so the caller can +decide what to do on failure (leave the barrier row / re-park the message) +rather than erase the only recovery handle. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from unstract.core.data_models import ExecutionStatus + +if TYPE_CHECKING: + from shared.api import InternalAPIClient + +logger = logging.getLogger(__name__) + + +def mark_execution_error( + api_client: InternalAPIClient, + execution_id: str, + organization_id: str, + *, + error_message: str, +) -> bool: + """Mark ``execution_id`` ERROR (+cascade non-terminal files); return success. + + Returns ``True`` iff the backend confirmed the write. NEVER raises: any + failure — a raised exception, or a ``success=False`` response (the internal + client reports some write failures that way rather than raising, mirroring + the reaper / read path) — is logged and returns ``False`` so the caller keeps + the recovery handle instead of erasing it. + + ``cascade_terminal_files=True`` marks the execution's non-terminal file + executions to ERROR atomically with the execution, so the two can't drift. + + The reaper keeps its own raise-on-failure variant (``_mark_stranded_error``): + its delete-guard needs the exception to refuse the barrier-row DELETE, whereas + the callers here branch on the returned bool. + """ + try: + response = api_client.update_workflow_execution_status( + execution_id=execution_id, + status=ExecutionStatus.ERROR.value, + error_message=error_message, + organization_id=organization_id, + cascade_terminal_files=True, + ) + except Exception: + logger.exception( + "Failed to mark execution %s ERROR (internal API raised) — leaving the " + "recovery handle in place for the reaper.", + execution_id, + ) + return False + # ``success`` absent → assume the raised-on-failure legacy contract (True). + if not getattr(response, "success", True): + logger.error( + "Marking execution %s ERROR reported success=False — leaving the " + "recovery handle in place for the reaper.", + execution_id, + ) + return False + # WARNING, not ERROR: this line records a successful recovery *action*; the + # underlying failure that caused the strand is already logged at ERROR by the + # caller (the batch failure / the poison drop), so ERROR here would double- + # count as alert noise for a non-error outcome. + logger.warning( + "Marked stranded execution %s ERROR (+cascade files): %s", + execution_id, + error_message, + ) + return True diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index 416f65795e..bffc2cce3f 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -329,9 +329,7 @@ def test_execute_phase_reaped_cached_conn_retries_once( assert "execute failed on a cached connection" in caplog.text assert type(exc).__name__ in caplog.text - def test_commit_phase_failure_is_not_retried( - self, _clean_local, monkeypatch, sleeps - ): + def test_commit_phase_failure_is_not_retried(self, _clean_local, monkeypatch, sleeps): # A commit failure is AMBIGUOUS (the server may have applied it) → must # NOT retry, or the decrement could land twice. It propagates; the dead # conn is discarded; no reconnect is attempted. @@ -377,9 +375,7 @@ def test_fresh_conn_execute_failure_is_not_retried( assert reconnects == [] # fresh-conn death is not retried assert sleeps == [] # …so no backoff either - def test_non_connection_error_is_not_retried( - self, _clean_local, monkeypatch, sleeps - ): + def test_non_connection_error_is_not_retried(self, _clean_local, monkeypatch, sleeps): # A DataError (e.g. a NUL byte rejected by the jsonb cast) is not a # connection death → propagate immediately so the caller tears the barrier # down. A live conn after a logical error is NOT discarded. @@ -459,7 +455,8 @@ def test_create_pg_connection_is_non_autocommit(): Opens a real connection inline (no fixture), so it's marked ``integration`` explicitly — the collection hook keys off fixture names and wouldn't catch - it, which would otherwise leave it in the DB-free unit lane.""" + it, which would otherwise leave it in the DB-free unit lane. + """ os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") try: conn = create_pg_connection(env_prefix="TEST_DB_") @@ -523,7 +520,8 @@ def _org(conn, execution_id): def _expires_in_seconds(conn, execution_id): """Seconds from *now* until the barrier's expires_at (DB-side now(), so no - client-clock skew) — the fixed 6h absolute cap.""" + client-clock skew) — the fixed 6h absolute cap. + """ with conn.cursor() as cur: cur.execute( "SELECT EXTRACT(EPOCH FROM (expires_at - now())) " @@ -535,7 +533,8 @@ def _expires_in_seconds(conn, execution_id): def _last_progress_age_seconds(conn, execution_id): """How long ago (DB-side) the barrier last made progress — the reaper's stuck - signal (UN-3661). Small = fresh; large = stalled.""" + signal (UN-3661). Small = fresh; large = stalled. + """ with conn.cursor() as cur: cur.execute( "SELECT EXTRACT(EPOCH FROM (now() - last_progress_at)) " @@ -562,9 +561,7 @@ def test_upsert_creates_row_and_attaches_links(self, barrier_db): cloned.link_error.assert_called_once() cloned.apply_async.assert_called_once() - def test_enqueue_sets_expires_cap_and_fresh_progress( - self, barrier_db, monkeypatch - ): + def test_enqueue_sets_expires_cap_and_fresh_progress(self, barrier_db, monkeypatch): # enqueue stamps expires_at = now()+ttl (the absolute cap) AND # last_progress_at = now() (fresh), so a just-enqueued barrier is neither # expired nor stale. (UN-3661) @@ -1161,10 +1158,29 @@ def test_run_batch_with_barrier_redelivery_skips_work_and_decrement(self, barrie remaining, _ = _row(barrier_db, "exec-re") assert remaining == 2 # NOT decremented again - def test_run_batch_with_barrier_exception_aborts_barrier(self, barrier_db): + def _dedup_count(self, conn, execution_id): + with conn.cursor() as cur: + cur.execute( + "SELECT count(*) FROM pg_batch_dedup WHERE execution_id = %s", + (execution_id,), + ) + return cur.fetchone()[0] + + def test_exception_marks_error_then_tears_down_keeping_markers( + self, barrier_db, monkeypatch + ): + # Confirmed ERROR mark → safe to tear the barrier row down, but KEEP the + # per-batch dedup marker so this message's redelivery is skipped by + # claim_batch (not re-run wholesale). with barrier_db.cursor() as cur: cur.execute("DELETE FROM pg_batch_dedup") _seed(barrier_db, "exec-err", 2) + marked = [] + monkeypatch.setattr( + pg_barrier, + "_mark_execution_error_on_abort", + lambda ctx, *, reason: marked.append(reason) or True, + ) ctx = { "execution_id": "exec-err", "batch_index": 0, @@ -1176,8 +1192,38 @@ def boom(): with pytest.raises(RuntimeError, match="batch failed"): run_batch_with_barrier(ctx, boom) - # No .link_error on the PG path → torn down in-body (mirror chord abort). + assert marked # execution marked ERROR before teardown + # Row torn down (mirror chord abort) but the dedup marker survives. assert _row(barrier_db, "exec-err") is None + assert self._dedup_count(barrier_db, "exec-err") == 1 # claim marker kept + + def test_exception_with_unconfirmed_mark_leaves_barrier_row( + self, barrier_db, monkeypatch + ): + # ERROR mark NOT confirmed (backend down / no org) → the barrier row is the + # reaper's only recovery handle, so it must be LEFT intact, not deleted. + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + _seed(barrier_db, "exec-strand", 2) + monkeypatch.setattr( + pg_barrier, + "_mark_execution_error_on_abort", + lambda ctx, *, reason: False, + ) + ctx = { + "execution_id": "exec-strand", + "batch_index": 0, + "callback_descriptor": _CALLBACK, + } + + def boom(): + raise RuntimeError("batch failed") + + with pytest.raises(RuntimeError, match="batch failed"): + run_batch_with_barrier(ctx, boom) + # Barrier row preserved for the reaper (remaining untouched). + remaining, _ = _row(barrier_db, "exec-strand") + assert remaining == 2 def test_fire_barrier_callback_pg_self_chains_via_dispatch(self): descriptor = {**_CALLBACK, "transport": "pg_queue"} @@ -1239,6 +1285,24 @@ def test_abort_clears_dedup_markers(self, barrier_db): ) assert cur.fetchone()[0] == 0 + def test_abort_preserve_flag_keeps_dedup_markers(self, barrier_db): + # preserve_dedup_markers=True (the in-body PG path): the barrier row is + # still deleted, but the marker survives so a redelivered batch is skipped + # by claim_batch rather than re-run wholesale. + with barrier_db.cursor() as cur: + cur.execute("DELETE FROM pg_batch_dedup") + _seed(barrier_db, "exec-keep", 2) + claim_batch("exec-keep", 0) + barrier_pg_abort(execution_id="exec-keep", preserve_dedup_markers=True) + assert _row(barrier_db, "exec-keep") is None # barrier row still deleted + with barrier_db.cursor() as cur: + cur.execute( + "SELECT count(*) FROM pg_batch_dedup WHERE execution_id = %s", + ("exec-keep",), + ) + assert cur.fetchone()[0] == 1 # marker preserved + assert claim_batch("exec-keep", 0) is False # redelivery would be skipped + def test_last_batch_self_chains_callback_to_pg_and_cleans_up(self, barrier_db): # The PR's core promise, end to end: the batch that drives remaining → 0 # self-chains the aggregating callback onto PG (backend=PG, args=[results]) @@ -1275,13 +1339,17 @@ def test_last_batch_self_chains_callback_to_pg_and_cleans_up(self, barrier_db): ) assert cur.fetchone()[0] == 0 - def test_decrement_failure_aborts_barrier(self, barrier_db): + def test_decrement_failure_aborts_barrier(self, barrier_db, monkeypatch): # #69: a decrement-side failure (after the work succeeded) must tear the # barrier down in-body — else the dedup marker is committed, redelivery is - # blocked, and the barrier strands to expiry. + # blocked, and the barrier strands to expiry. With a confirmed ERROR mark + # the row is torn down (same path as a work failure). with barrier_db.cursor() as cur: cur.execute("DELETE FROM pg_batch_dedup") _seed(barrier_db, "exec-decfail", 2) + monkeypatch.setattr( + pg_barrier, "_mark_execution_error_on_abort", lambda ctx, *, reason: True + ) ctx = { "execution_id": "exec-decfail", "batch_index": 0, @@ -1337,5 +1405,73 @@ def dispatch_side_effect(*args, **kwargs): assert cur.fetchone()[0] == 0 +class TestMarkExecutionErrorOnAbort: + """``_mark_execution_error_on_abort`` — the in-body PG failure → terminal mark + (no DB; the internal API client + mark helper are mocked). + """ + + def _ctx(self, *, org): + kwargs = {"execution_id": "exec-1", "pipeline_id": "pipe-1"} + if org is not None: + kwargs["organization_id"] = org + return { + "execution_id": "exec-1", + "batch_index": 0, + "callback_descriptor": {**_CALLBACK, "kwargs": kwargs}, + } + + def test_no_org_returns_false_without_building_client(self, monkeypatch): + # No org → can't call the org-scoped API; must NOT build a client or mark. + built = MagicMock(side_effect=AssertionError("client built without org")) + monkeypatch.setattr("shared.api.InternalAPIClient", built) + assert ( + pg_barrier._mark_execution_error_on_abort( + self._ctx(org=None), reason="batch 0 failed" + ) + is False + ) + + def test_org_present_marks_error_and_returns_helper_result(self, monkeypatch): + client = MagicMock(name="api_client") + monkeypatch.setattr( + "shared.api.InternalAPIClient", MagicMock(return_value=client) + ) + mark = MagicMock(return_value=True) + monkeypatch.setattr("queue_backend.pg_queue.recovery.mark_execution_error", mark) + out = pg_barrier._mark_execution_error_on_abort( + self._ctx(org="org-9"), reason="batch 0 failed" + ) + assert out is True + mark.assert_called_once() + args, kwargs = mark.call_args + assert args[0] is client + assert args[1] == "exec-1" + assert args[2] == "org-9" + assert kwargs["error_message"] == "[pg-barrier-abort] batch 0 failed." + + def test_helper_false_propagates(self, monkeypatch): + monkeypatch.setattr( + "shared.api.InternalAPIClient", MagicMock(return_value=MagicMock()) + ) + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", + MagicMock(return_value=False), + ) + assert ( + pg_barrier._mark_execution_error_on_abort(self._ctx(org="org-9"), reason="x") + is False + ) + + def test_client_build_failure_returns_false(self, monkeypatch): + monkeypatch.setattr( + "shared.api.InternalAPIClient", + MagicMock(side_effect=RuntimeError("no config")), + ) + assert ( + pg_barrier._mark_execution_error_on_abort(self._ctx(org="org-9"), reason="x") + is False + ) + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/workers/tests/test_pg_queue_client.py b/workers/tests/test_pg_queue_client.py index 939d319e5a..280af5a5ae 100644 --- a/workers/tests/test_pg_queue_client.py +++ b/workers/tests/test_pg_queue_client.py @@ -117,6 +117,26 @@ def test_delete_returns_false_when_no_row(self): conn, _ = _mock_conn(rowcount=0) assert PgQueueClient(conn=conn).delete(999) is False + def test_set_vt_reparks_message(self): + conn, cur = _mock_conn(rowcount=1) + assert PgQueueClient(conn=conn).set_vt(42, 300) is True + sql, params = cur.execute.call_args.args + assert f"UPDATE {qualified('pg_queue_message')}" in sql + assert "SET vt = now() + make_interval(secs => %s)" in sql + assert "read_ct" not in sql # re-park must NOT bump the delivery count + assert params == (300, 42) + conn.commit.assert_called_once() + + def test_set_vt_returns_false_when_no_row(self): + conn, _ = _mock_conn(rowcount=0) + assert PgQueueClient(conn=conn).set_vt(999, 300) is False + + def test_set_vt_rejects_non_positive(self): + conn, _ = _mock_conn() + client = PgQueueClient(conn=conn) + with pytest.raises(ValueError, match="vt_seconds"): + client.set_vt(1, 0) + def test_read_rejects_non_positive_vt(self): conn, _ = _mock_conn() with pytest.raises(ValueError, match="vt_seconds"): diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index 9ccbd1ab85..5f7b12b753 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -114,7 +114,15 @@ def test_fairness_header_rebuilt_for_run(self): app.tasks.get.return_value = task client = MagicMock() client.read.return_value = [ - _msg(6, {"task_name": "t", "args": [1], "kwargs": {"k": "v"}, "fairness": fairness}) + _msg( + 6, + { + "task_name": "t", + "args": [1], + "kwargs": {"k": "v"}, + "fairness": fairness, + }, + ) ] PgQueueConsumer(["q"], client=client, app=app).poll_once() kwargs = task.apply.call_args.kwargs @@ -150,24 +158,235 @@ def test_empty_batch_acks_nothing(self): client.delete.assert_not_called() +def _callback_payload(execution_id="exec-1", organization_id="org-1"): + """A barrier aggregating-callback payload: execution identity in kwargs, no + reply_key / on_error (the sharpest silent-strand case). + """ + return { + "task_name": "process_batch_callback_api", + "args": [[]], + "kwargs": { + "execution_id": execution_id, + "organization_id": organization_id, + "pipeline_id": "pipe-1", + }, + } + + +class TestPipelineIdentity: + """``_pipeline_identity`` extraction across the payload shapes.""" + + def test_from_callback_kwargs(self): + from queue_backend.pg_queue.consumer import _pipeline_identity + + assert _pipeline_identity(_callback_payload("e", "o")) == ("e", "o") + + def test_from_barrier_context_and_fairness(self): + from queue_backend.pg_queue.consumer import _pipeline_identity + + payload = { + "task_name": "process_file_batch", + "kwargs": {"_barrier_context": {"execution_id": "e2"}}, + "fairness": {"org_id": "o2", "workload_type": "api"}, + } + assert _pipeline_identity(payload) == ("e2", "o2") + + def test_org_from_barrier_callback_descriptor(self): + from queue_backend.pg_queue.consumer import _pipeline_identity + + payload = { + "kwargs": { + "_barrier_context": { + "execution_id": "e3", + "callback_descriptor": {"kwargs": {"organization_id": "o3"}}, + } + } + } + assert _pipeline_identity(payload) == ("e3", "o3") + + def test_non_pipeline_returns_none(self): + from queue_backend.pg_queue.consumer import _pipeline_identity + + assert _pipeline_identity({"task_name": "t", "kwargs": {}}) == (None, "") + + def test_execution_without_org_returns_empty_org(self): + from queue_backend.pg_queue.consumer import _pipeline_identity + + payload = {"kwargs": {"execution_id": "e4"}} + assert _pipeline_identity(payload) == ("e4", "") + + +class TestPoisonDropMarksExecution: + """UN-3670: a poison drop with no reply channel marks the execution ERROR + (or re-parks if the mark can't be confirmed) instead of silently discarding. + """ + + def _poison(self, msg_id=5, payload=None, read_ct=6): + return _msg(msg_id, payload or _callback_payload(), read_ct=read_ct) + + def test_marks_error_then_deletes(self, monkeypatch): + marks = [] + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", + lambda client, eid, org, *, error_message: marks.append((eid, org)) or True, + ) + client = MagicMock() + client.read.return_value = [self._poison()] + PgQueueConsumer( + ["q"], client=client, api_client=MagicMock(), max_attempts=5 + ).poll_once() + assert marks == [("exec-1", "org-1")] # marked ERROR + client.delete.assert_called_once_with(5) # then dropped + client.set_vt.assert_not_called() + + def test_reparks_when_mark_unconfirmed(self, monkeypatch): + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", + lambda *a, **k: False, # backend down + ) + client = MagicMock() + client.read.return_value = [self._poison(read_ct=6)] + PgQueueConsumer( + ["q"], client=client, api_client=MagicMock(), max_attempts=5 + ).poll_once() + client.delete.assert_not_called() # NOT dropped into a void + client.set_vt.assert_called_once_with(5, 300) # re-parked long + + def test_drops_after_repark_budget_exhausted(self, monkeypatch, caplog): + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", + lambda *a, **k: False, + ) + client = MagicMock() + # read_ct beyond max_attempts + budget → give up and drop. + client.read.return_value = [self._poison(read_ct=11)] + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): + PgQueueConsumer( + ["q"], + client=client, + api_client=MagicMock(), + max_attempts=5, + poison_repark_budget=5, + ).poll_once() + client.set_vt.assert_not_called() + client.delete.assert_called_once_with(5) + assert "re-park budget" in caplog.text + + def test_reply_channel_keeps_existing_behavior(self, monkeypatch): + # A message with on_error surfaces on its channel and is dropped — NOT + # marked ERROR (it already has a failure channel). + marked = MagicMock() + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", marked + ) + payload = { + "task_name": "some.executor", + "on_error": {"task_name": "cb", "queue": "q"}, + "kwargs": {"execution_id": "exec-1", "organization_id": "org-1"}, + } + client = MagicMock() + client.read.return_value = [self._poison(payload=payload)] + consumer = PgQueueConsumer(["q"], client=client, max_attempts=5) + monkeypatch.setattr(consumer, "_chain_continuation", MagicMock()) + consumer.poll_once() + marked.assert_not_called() # reply channel path, no execution mark + client.delete.assert_called_once_with(5) + client.set_vt.assert_not_called() + + def test_no_execution_id_drops_without_marking(self, monkeypatch): + marked = MagicMock() + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", marked + ) + client = MagicMock() + client.read.return_value = [ + _msg(5, {"task_name": "test_pg_consumer.boom"}, read_ct=6) + ] + PgQueueConsumer(["q"], client=client, max_attempts=5).poll_once() + marked.assert_not_called() + client.delete.assert_called_once_with(5) + + def test_repark_budget_boundary_still_reparks(self, monkeypatch): + # Gap A: at exactly read_ct == max_attempts + budget (10) the message must + # still re-park; only strictly beyond it drops. Guards the >/>= boundary. + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", + lambda *a, **k: False, + ) + client = MagicMock() + client.read.return_value = [self._poison(read_ct=10)] # 5 + 5 + PgQueueConsumer( + ["q"], + client=client, + api_client=MagicMock(), + max_attempts=5, + poison_repark_budget=5, + ).poll_once() + client.set_vt.assert_called_once_with(5, 300) # re-parked, not dropped + client.delete.assert_not_called() + + def test_no_org_poison_drops_immediately_without_marking(self, monkeypatch): + # Gap B: an execution_id but no org can never be marked (org-scoped API), + # so it drops immediately — no wasted re-park budget, no mark attempt. + marked = MagicMock() + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", marked + ) + client = MagicMock() + client.read.return_value = [ + self._poison( + payload={ + "task_name": "process_file_batch", + "kwargs": {"execution_id": "e4"}, + } + ) + ] + PgQueueConsumer( + ["q"], client=client, api_client=MagicMock(), max_attempts=5 + ).poll_once() + marked.assert_not_called() # never attempted (no org) + client.set_vt.assert_not_called() # not re-parked (permanent) + client.delete.assert_called_once_with(5) # dropped now + + def test_api_client_build_failure_reparks(self, monkeypatch): + # Gap C: with no injected api_client, a build failure is transient — the + # message re-parks (not drops) so a recovered backend can still mark it. + monkeypatch.setattr( + "shared.api.InternalAPIClient", + MagicMock(side_effect=RuntimeError("no config")), + ) + marked = MagicMock() + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", marked + ) + client = MagicMock() + client.read.return_value = [self._poison(read_ct=6)] # org present + PgQueueConsumer(["q"], client=client, max_attempts=5).poll_once() + marked.assert_not_called() # never reached — client build raised first + client.set_vt.assert_called_once_with(5, 300) # re-parked + client.delete.assert_not_called() + + class TestConstruction: def test_rejects_non_positive_params(self): + client = MagicMock() for kw in ( {"batch_size": 0}, {"vt_seconds": -1}, {"poll_interval": 0}, {"backoff_max": 0}, {"max_attempts": 0}, + {"poison_repark_vt_seconds": 0}, + {"poison_repark_budget": -1}, ): with pytest.raises(ValueError): - PgQueueConsumer(["q"], client=MagicMock(), **kw) + PgQueueConsumer(["q"], client=client, **kw) def test_rejects_backoff_max_below_poll_interval(self): # Otherwise backoff would shrink below poll_interval instead of growing. + client = MagicMock() with pytest.raises(ValueError, match="backoff_max"): - PgQueueConsumer( - ["q"], client=MagicMock(), poll_interval=0.5, backoff_max=0.1 - ) + PgQueueConsumer(["q"], client=client, poll_interval=0.5, backoff_max=0.1) class TestRunLoop: @@ -389,7 +608,9 @@ def test_liveness_aliases_and_unknown_path(self): def test_double_start_is_rejected(self): from queue_backend.pg_queue.consumer import LivenessServer - server = LivenessServer(PgQueueConsumer(["q"], client=MagicMock()), port=0, stale_after=60) + server = LivenessServer( + PgQueueConsumer(["q"], client=MagicMock()), port=0, stale_after=60 + ) server.start() try: with pytest.raises(RuntimeError, match="already started"): @@ -453,7 +674,8 @@ def test_rejects_empty_queue_list(self): def test_one_queue_failing_does_not_abort_the_others(self): """A read failure on one queue is isolated: the already-acked work this cycle still counts (so run() doesn't take the empty backoff path), and - the failure doesn't propagate out of poll_once.""" + the failure doesn't propagate out of poll_once. + """ client = MagicMock() client.read.side_effect = [[_msg(1, _ok_payload(1))], RuntimeError("boom")] claimed = PgQueueConsumer(["qa", "qb"], client=client).poll_once() @@ -534,9 +756,7 @@ def test_store_failure_on_success_path_still_acks(self, caplog): client.read.return_value = [_msg(4, {**_ok_payload(1, 1), "reply_key": "rk4"})] with patch(self._RB) as rb_cls: rb_cls.return_value.store_result.side_effect = RuntimeError("db down") - with caplog.at_level( - logging.ERROR, logger="queue_backend.pg_queue.consumer" - ): + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.consumer"): PgQueueConsumer(["q"], client=client).poll_once() # A store failure must NOT block the ack (avoids an expensive re-run). client.delete.assert_called_once_with(4) @@ -598,7 +818,11 @@ def test_early_drop_branches_still_chain_on_error(self): # (msg_id, payload, read_ct) — malformed / unknown / poison (10, {"on_error": err, "task_id": "t"}, 1), (11, {"task_name": "nope.nope", "on_error": err, "task_id": "t"}, 1), - (12, {"task_name": "test_pg_consumer.boom", "on_error": err, "task_id": "t"}, 99), + ( + 12, + {"task_name": "test_pg_consumer.boom", "on_error": err, "task_id": "t"}, + 99, + ), ] for msg_id, payload, read_ct in cases: client = MagicMock() diff --git a/workers/tests/test_pg_recovery.py b/workers/tests/test_pg_recovery.py new file mode 100644 index 0000000000..2c82b1f9dd --- /dev/null +++ b/workers/tests/test_pg_recovery.py @@ -0,0 +1,63 @@ +"""Tests for the shared terminal-ERROR recovery helper (mark_execution_error). + +Pure unit tests with a mocked internal API client — no Postgres, no HTTP. +""" + +from __future__ import annotations + +import logging +from types import SimpleNamespace +from unittest.mock import MagicMock + +from queue_backend.pg_queue.recovery import mark_execution_error +from unstract.core.data_models import ExecutionStatus + + +def _api(*, success=True, raises=None): + client = MagicMock() + if raises is not None: + client.update_workflow_execution_status.side_effect = raises + else: + client.update_workflow_execution_status.return_value = SimpleNamespace( + success=success + ) + return client + + +def test_marks_error_with_cascade_and_returns_true(): + client = _api(success=True) + ok = mark_execution_error(client, "exec-1", "org-1", error_message="batch failed") + assert ok is True + client.update_workflow_execution_status.assert_called_once() + _, kwargs = client.update_workflow_execution_status.call_args + assert kwargs["execution_id"] == "exec-1" + assert kwargs["organization_id"] == "org-1" + assert kwargs["status"] == ExecutionStatus.ERROR.value + assert kwargs["error_message"] == "batch failed" + # Cascade is the whole point — files must not be left EXECUTING. + assert kwargs["cascade_terminal_files"] is True + + +def test_success_false_returns_false_and_logs(caplog): + client = _api(success=False) + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.recovery"): + ok = mark_execution_error(client, "exec-2", "org-1", error_message="x") + assert ok is False + assert "success=False" in caplog.text + + +def test_exception_is_swallowed_and_returns_false(caplog): + client = _api(raises=RuntimeError("backend down")) + with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.recovery"): + ok = mark_execution_error(client, "exec-3", "org-1", error_message="x") + # Never raises — the caller keeps its recovery handle instead of crashing. + assert ok is False + assert "internal API raised" in caplog.text + + +def test_absent_success_attr_assumed_true(): + # A legacy raise-on-failure response with no ``success`` attribute is treated + # as success (it would have raised otherwise). + client = MagicMock() + client.update_workflow_execution_status.return_value = object() + assert mark_execution_error(client, "e", "o", error_message="x") is True From 02b6f1b93fabfc9c298ad78bed488f6e2d237dc8 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:41:50 +0530 Subject: [PATCH 56/65] =?UTF-8?q?UN-3671=20[FIX]=20PG=20orchestrator=20?= =?UTF-8?q?=E2=80=94=20idempotency=20claim=20for=20async=5Fexecute=5Fbin?= =?UTF-8?q?=20(double-orchestration=20guard)=20(#2141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3671 [FIX] PG orchestrator — idempotency claim for async_execute_bin (double-orchestration guard) async_execute_bin has no idempotency guard — its docstring explicitly allows concurrent executions. On the PG queue this is unsafe under redelivery: if the orchestration runs longer than the consumer's visibility timeout and a sibling replica re-claims the message, the orchestration runs twice — the barrier UPSERT re-arms remaining=N and deletes the first run's pg_batch_dedup markers mid-flight, a second set of N batch headers dispatches, and up to 2N decrements race remaining=N (early/incorrect callback + double LLM spend). Today this is avoided only because every PG consumer is single-replica/concurrency-1 — correctness by deployment accident. The documented scaling path ("scale replicas") arms it. Add an execution-level claim, analogous to claim_batch one level up: - New pg_orchestration_claim table (backend model + migration 0012): one row per execution_id. Chosen over a pg_batch_dedup sentinel because a sentinel would need two changes to merged code (relax the batch_index >= 0 CHECK and exclude it from the barrier-arm blanket DELETE); a dedicated table touches no merged SQL. - claim_orchestration() in pg_barrier (INSERT ... ON CONFLICT DO NOTHING RETURNING): the first delivery inserts and orchestrates; a duplicate finds the row and no-ops. Two racing replicas resolve to exactly one True. - _should_skip_duplicate_orchestration() gate in async_execute_bin_general, claimed BEFORE any setup / barrier arm / batch dispatch. Claim-before-work: a crash mid-orchestration leaves the claim, the redelivery no-ops, and the reaper recovers the stranded execution (same crash-window semantics as claim_batch). The claim is a tombstone — deliberately NOT cleared at finalise, so a redelivery after completion can't re-orchestrate a finished execution. PG-transport-only: the gate returns False on Celery (is_pg_transport), which has no at-least-once redelivery, so the Celery path never claims and is behaviorally unchanged (proven by test). Registered the new table in schema.QUEUE_TABLES. Tests: claim_orchestration first-wins / duplicate-loses / distinct-executions / tombstone against real Postgres; the transport-gated gate (Celery never claims; PG first proceeds, duplicate skips); and async_execute_bin_general short-circuits to the skip result without setup. Unit lane 1135 passed; integration green. Deploy: apply the migration before PG workers claim (migrate-first; flag-gated, inert until pg_queue_enabled). Co-Authored-By: Claude Fable 5 * UN-3671 [FIX] Address PR review — release claim on failure, honest docstrings, hardening + tests PR Review Toolkit (6 agents) on PR #2141. Blocker 1 (retry defeat) — FIXED. The claim is committed before the work but was never released on failure, so a transient first-attempt failure left it committed and every redelivery/retry then no-op'd — a silently-lost execution returning a success-shaped dict. Added release_orchestration_claim(), called on the orchestrator's error path (PG-gated, best-effort), so a failed attempt frees the slot and the redelivery re-orchestrates. The claim persists only after success (the post-completion tombstone). Blocker 2 (crash-window strand not reaper-recoverable) + the no-GC tombstone — docstrings corrected to state the truth (the claim is taken before the barrier is armed, so a hard crash in the claim->arm window leaves a claim the reaper's barrier sweep can't see; and the LIVE sweep_orphan_dedup covers only pg_batch_dedup, so this table has no GC yet). The reaper fix (GC + crash-window recovery, needs an organization_id column) is filed as follow-up UN-3679; the pg_queue_enabled gate stays off until it lands. Also addressed: - Deploy hazard: try_claim_orchestration catches UndefinedTable and re-raises an actionable "migration 0012 not applied" error instead of a generic per-execution stack trace (no silent table-missing fallback). - Observability: _should_skip logs at ERROR with errorId ORCH_CLAIM_SUPPRESSED_ RETRY when it skips on retries > 0 (a suppressed retry, not a benign duplicate). - Readability: renamed claim_orchestration -> try_claim_orchestration (the try_ idiom = "returns whether it won", killing the double-negative at the call site). - Type-design: tombstone invariant marked in the model (no finalise-time DELETE). - Tests: concurrent racing claims (N threads + threading.Barrier, exactly one winner over several executions); claim-error propagation; release-allows-reclaim + release-is-safe-when-absent; retry-skip logs the errorId; reframed the __globals__ test comment as belt-and-braces. Unit lane 1137 passed; orchestration integration 7 green vs local Postgres. Co-Authored-By: Claude Fable 5 * UN-3671 [FIX] Address Greptile — stale-conn retry for the claim + release only when acquired Two Greptile P2s on PR #2141. G1 — try_claim_orchestration had no reconnect-retry, yet it's the FIRST DB write of the orchestration task, so on an idle worker whose PgBouncer connection was reaped the very first INSERT fails and marks the execution ERROR before any work. Added the same execute-phase-only phase-split retry as _apply_decrement: retry ONCE, but only on an execute-phase failure of a *cached* connection (the INSERT provably never committed — non-autocommit rolls back on disconnect — so the retry's RETURNING win/lost answer is authoritative, no flip). A commit-phase failure is ambiguous and a fresh-conn failure is a real error — neither is retried, so a real winner is never flipped to a loser (which would strand the execution). This is why the idempotent-pre-dispatch helper (-> None, no RETURNING) can't be reused here. G2 — the error path called release_orchestration_claim whenever is_pg_transport was true, including when the claim's own INSERT raised (nothing acquired) — a spurious DELETE that reconnects to delete nothing and muddies the log. Track a claimed_orchestration flag (set only after the gate returns "proceed" on PG) and release only when we actually won the claim. Tests: claim retry — execute-phase reaped-cached-conn retries once; commit-phase not retried; fresh-conn not retried (mirrors the decrement phase-split tests). G2 — no release when the claim never acquired; release when claimed then failed. Unit lane 1142 passed; integration green (the one failure is the pre-existing UN-3676 shared-lane flake, passes in isolation). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../migrations/0012_pgorchestrationclaim.py | 23 ++ backend/pg_queue/models.py | 55 ++++ workers/general/tasks.py | 88 ++++++ workers/queue_backend/pg_barrier.py | 127 ++++++++ workers/queue_backend/pg_queue/schema.py | 6 +- .../tests/test_orchestration_idempotency.py | 289 ++++++++++++++++++ workers/tests/test_pg_barrier.py | 69 +++++ 7 files changed, 655 insertions(+), 2 deletions(-) create mode 100644 backend/pg_queue/migrations/0012_pgorchestrationclaim.py create mode 100644 workers/tests/test_orchestration_idempotency.py diff --git a/backend/pg_queue/migrations/0012_pgorchestrationclaim.py b/backend/pg_queue/migrations/0012_pgorchestrationclaim.py new file mode 100644 index 0000000000..55cf0f09cc --- /dev/null +++ b/backend/pg_queue/migrations/0012_pgorchestrationclaim.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.30 on 2026-07-02 11:57 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0011_pgbarrierstate_last_progress_at"), + ] + + operations = [ + migrations.CreateModel( + name="PgOrchestrationClaim", + fields=[ + ("execution_id", models.TextField(primary_key=True, serialize=False)), + ("claimed_at", models.DateTimeField(default=django.utils.timezone.now)), + ], + options={ + "db_table": "pg_orchestration_claim", + }, + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index c81fb715df..54f0b96184 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -343,3 +343,58 @@ class Meta: # expires_at <= now()); no sweeper reads it yet (see class docstring). models.Index(fields=["expires_at"], name="pg_task_result_expires_idx"), ] + + +class PgOrchestrationClaim(models.Model): + """Per-execution idempotency claim for the orchestration task (PG transport). + + The PG queue is at-least-once: the orchestration task ``async_execute_bin`` + can be redelivered (it runs longer than the consumer's visibility timeout and + a sibling replica re-claims the message) or delivered to two replicas. Re- + running the orchestration is destructive — it re-arms the barrier (resets + ``remaining = N`` and wipes the ``pg_batch_dedup`` markers mid-flight), + dispatches a second set of N batch headers, and races the decrements. This + table is the durable single-winner token that closes that window: one row per + ``execution_id``. + + The worker claims by inserting its row atomically + (``INSERT … ON CONFLICT DO NOTHING RETURNING``): a fresh insert means "first + delivery — orchestrate"; a conflict means "already claimed — a duplicate / + redelivery → no-op". Claimed BEFORE the work; the claim is **released on + failure** (the orchestrator's error path deletes it) so a redelivery/retry can + re-orchestrate. It persists only after a SUCCESSFUL orchestration, as a + tombstone — deliberately kept past barrier finalise (unlike the per-batch + markers, which ``clear_execution_batches`` reclaims), so a redelivery AFTER + completion (when the barrier row is already gone) can't re-orchestrate a + finished execution. + + **Recovery gaps (follow-up — the gate stays off until then):** unlike + ``pg_batch_dedup`` — whose barrier is always armed before any batch runs, so + the reaper always has a ``pg_barrier_state`` handle — this claim is taken + BEFORE the barrier is armed. A hard crash in the claim→arm window leaves a + claim with no barrier row, invisible to the reaper's barrier sweep. And the + live ``sweep_orphan_dedup`` reclaims only ``pg_batch_dedup`` (it never touches + this table), so a successful claim's tombstone currently has NO GC and lives + forever. Both are addressed by extending the reaper to sweep this table by + ``claimed_at`` age (GC terminal/aged claims; mark a non-terminal orphan-claim + execution ERROR). Bounded meanwhile: one row per execution, and the gate is + off. + + Only written on the PG transport (``is_pg_transport``) — the Celery path never + touches it. Managed=True / generated migration, extension-free — same posture + as the siblings. + """ + + # One row per execution; the claim's ON CONFLICT target and the writer-proof + # single-winner invariant (the worker SQL can't import this model). Tombstone: + # deliberately never deleted at barrier finalise (there is no orchestration- + # claim analog of clear_execution_batches) — that is what blocks a + # post-completion redelivery from re-orchestrating. A future maintainer must + # NOT add a finalise-time DELETE here. + execution_id = models.TextField(primary_key=True) + # Observability/debugging today (when the orchestration was claimed). The + # planned reaper sweep would reclaim by this column's age and add an index then. + claimed_at = models.DateTimeField(default=timezone.now) + + class Meta: + db_table = "pg_orchestration_claim" diff --git a/workers/general/tasks.py b/workers/general/tasks.py index 877f867056..3a4d772db1 100644 --- a/workers/general/tasks.py +++ b/workers/general/tasks.py @@ -9,6 +9,10 @@ from queue_backend import FairnessKey, worker_task from queue_backend.fairness import WorkloadType +from queue_backend.pg_barrier import ( + release_orchestration_claim, + try_claim_orchestration, +) from scheduler.tasks import execute_pipeline_task_v2 # Import shared worker infrastructure using new structure @@ -138,6 +142,52 @@ def _log_batch_creation_statistics( ) +def _should_skip_duplicate_orchestration( + execution_id: str, transport: str, retries: int = 0 +) -> bool: + """PG-only: claim the execution's orchestration slot; ``True`` iff THIS + delivery lost the claim (a duplicate / redelivered orchestration) and must + no-op. + + On the PG queue the orchestration task can be redelivered — it runs longer + than the consumer's visibility timeout and a sibling replica re-claims the + message — and re-running it re-arms the barrier (resets ``remaining`` and + wipes the dedup markers mid-flight) and dispatches a second set of batch + headers. :func:`try_claim_orchestration` is the single-winner gate: the first + delivery inserts the claim and proceeds; a duplicate finds it and this returns + ``True`` so the caller no-ops. + + ``retries`` (``self.request.retries``) only sharpens the log: a skip on a + *first* delivery is a benign duplicate, but a skip when ``retries > 0`` means + a prior attempt claimed and did not release it (a hard crash after the claim, + before the failure-path release) — a suppressed retry, logged at ERROR with an + errorId so it's distinguishable from a normal duplicate. + + No-op on Celery (returns ``False`` — always proceeds): Celery has no + at-least-once redelivery, and the router owns terminal status there, so there + is no double-orchestration to guard. Gated exactly like the terminal guard so + the Celery path is behaviorally unchanged. + """ + if not is_pg_transport(transport): + return False + if try_claim_orchestration(execution_id): + return False # first delivery — this replica owns the orchestration + if retries > 0: + logger.error( + f"[exec:{execution_id}] orchestration claim already held on retry " + f"{retries} — a prior attempt claimed but never released it (crash " + f"before the failure-path release); this retry is being SUPPRESSED and " + f"the execution may be stranded. errorId=ORCH_CLAIM_SUPPRESSED_RETRY" + ) + else: + logger.info( + f"[exec:{execution_id}] orchestration already claimed — duplicate/" + f"redelivered orchestration on the PG queue; skipping (no barrier " + f"re-arm, no re-dispatch)." + ) + return True + + @worker_task( bind=True, name=TaskName.ASYNC_EXECUTE_BIN_GENERAL, @@ -195,7 +245,28 @@ def async_execute_bin_general( f"Starting general workflow execution for workflow {workflow_id}, execution {execution_id}" ) + # Tracks whether THIS delivery won the PG orchestration claim, so the + # error path releases the claim only when we actually acquired it (not, for + # instance, when the claim's own INSERT raised). Set below only after the + # gate returns "proceed" on the PG path. + claimed_orchestration = False try: + # PG-only idempotency gate (claim-before-work): a duplicate / + # redelivered orchestration no-ops here BEFORE any setup, barrier arm, + # or batch dispatch — so exactly one delivery orchestrates the + # execution even with replicas > 1. No-op on Celery. + if _should_skip_duplicate_orchestration( + execution_id, transport, self.request.retries + ): + return { + "status": "skipped_duplicate_orchestration", + "execution_id": execution_id, + "workflow_id": workflow_id, + } + # Gate returned "proceed": on PG that means this delivery just won the + # claim (Celery never claims), so the error path owns releasing it. + claimed_orchestration = is_pg_transport(transport) + # Initialize execution context with shared utility config, api_client = WorkerExecutionContext.setup_execution_context( schema_name, execution_id, workflow_id @@ -320,6 +391,23 @@ def async_execute_bin_general( except Exception as e: logger.error(f"General workflow execution failed for {execution_id}: {e}") + # Release the orchestration claim so a redelivery/retry can + # re-orchestrate. The claim is taken before the work, so without this a + # transient failure would leave it committed and make every redelivery + # permanently no-op — a silently-lost execution. Only when THIS delivery + # actually won the claim (not, e.g., when the claim's own INSERT raised — + # nothing to release, and a spurious DELETE would just reconnect to + # delete nothing and muddy the log). Best-effort: a failed release just + # means the redelivery skips and the execution stays ERROR. + if claimed_orchestration: + try: + release_orchestration_claim(execution_id) + except Exception as release_error: + logger.warning( + f"[exec:{execution_id}] failed to release orchestration " + f"claim on error path: {release_error}" + ) + # Try to update execution status to failed try: with InternalAPIClient(config) as api_client: diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 6b3fdb69a3..289fc8b631 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -74,6 +74,7 @@ from typing import TYPE_CHECKING, Any, Final, NamedTuple import psycopg2 +import psycopg2.errors import psycopg2.extensions from unstract.core.data_models import ( @@ -305,6 +306,132 @@ def clear_execution_batches(execution_id: str) -> int: return cur.rowcount +def try_claim_orchestration(execution_id: str) -> bool: + """Try to claim the orchestration slot for ``execution_id`` — the + execution-level idempotency gate mirroring :func:`claim_batch`, one level up. + The ``try_`` prefix signals the return is "did I win": ``True`` = claimed + (proceed), ``False`` = already claimed (a duplicate/redelivery — no-op). + + Atomically inserts the single ``pg_orchestration_claim`` row via + ``INSERT … ON CONFLICT DO NOTHING RETURNING`` (a single statement, so two + replicas racing the same orchestration resolve to exactly one ``True`` — the + row's existence is the token). + + Claimed BEFORE the orchestration work (arm barrier / dispatch batches). The + claim is **released on failure** (see :func:`release_orchestration_claim`, + called from the orchestrator's error path) so a redelivery/retry can + re-orchestrate; it persists only after a SUCCESSFUL orchestration, as a + tombstone that blocks a redelivery AFTER the execution completes (when the + barrier row is already gone) from re-arming and re-dispatching. + + **Recovery gaps (tracked as follow-up — flag stays off until then):** unlike + :func:`claim_batch`, whose barrier is always armed before any batch runs (so + the reaper always has a ``pg_barrier_state`` handle), this claim is taken + BEFORE the barrier is armed. A hard crash (SIGKILL / eviction) in the + claim→arm window leaves a claim with no barrier row, which the reaper's + barrier sweep cannot see. And ``sweep_orphan_dedup`` reclaims only + ``pg_batch_dedup`` — nothing GCs ``pg_orchestration_claim`` yet, so a + successful claim's tombstone currently lives forever. Both are addressed by + extending the reaper to sweep this table (GC terminal/aged claims; mark a + non-terminal orphan-claim execution ERROR). + + PG path only — the caller gates on the transport (Celery has no redelivery, so + no double-orchestration to guard). + + **Stale-connection resilience (phase-split, like :func:`_apply_decrement`).** + This is the FIRST DB write of the orchestration task, so on a worker that has + been idle it is the one most likely to meet a PgBouncer-reaped connection. The + INSERT is retried ONCE, but ONLY on an execute-phase failure of a *cached* + connection: the statement never committed (non-autocommit → rolled back on + disconnect), so re-running it lands exactly once and the retry's ``RETURNING`` + answer is authoritative — no won/lost flip. A *commit*-phase failure is + AMBIGUOUS (the server may have committed) and is NEVER retried — a re-run could + flip a real winner to a loser and strand the execution — so it propagates (the + orchestrator marks ERROR and the redelivery re-orchestrates). This is why the + idempotent-pre-dispatch helper (``-> None``, no RETURNING) can't be reused here. + """ + # Sample cached-ness BEFORE _get_conn() below materialises a connection: only a + # cached handle can be a stale idle-reap, so a fresh-conn failure is a genuine + # DB error, not a reap (mirrors _apply_decrement / pg_queue.client.send). + reused = getattr(_local, "conn", None) is not None and not _local.conn.closed + try: + for attempt in range(1, _BARRIER_WRITE_ATTEMPTS + 1): + conn = _get_conn() + try: + with conn.cursor() as cur: + cur.execute( + f"INSERT INTO {qualified('pg_orchestration_claim')} " + "(execution_id, claimed_at) VALUES (%s, now()) " + "ON CONFLICT (execution_id) DO NOTHING " + "RETURNING execution_id", + (execution_id,), + ) + won = cur.fetchone() is not None + except Exception as exc: + conn_dead = _recover_after_error(conn, exc) + # Retry ONLY a reused-conn death on the execute phase: it never + # committed, so the retry (on a fresh conn) is authoritative. + if conn_dead and reused and attempt < _BARRIER_WRITE_ATTEMPTS: + logger.warning( + "[exec:%s] orchestration claim: execute failed on a cached " + "connection (%s) — reconnecting and retrying once " + "(attempt %d/%d); the INSERT never committed.", + execution_id, + type(exc).__name__, + attempt, + _BARRIER_WRITE_ATTEMPTS, + exc_info=True, + ) + time.sleep(_BARRIER_RETRY_BACKOFF_SECONDS) + continue + raise + try: + conn.commit() + except Exception as exc: + # AMBIGUOUS commit — the row may be persisted. Re-running could flip + # a real winner to a loser (→ strand), so NEVER retry; propagate. + _recover_after_error(conn, exc) + logger.warning( + "[exec:%s] orchestration claim: commit failed (%s) — NOT " + "retrying (the server may already have committed; a re-run " + "could flip the winner). Propagating.", + execution_id, + type(exc).__name__, + exc_info=True, + ) + raise + return won + # Unreachable: the loop either returns or raises. + raise AssertionError("try_claim_orchestration loop fell through") + except psycopg2.errors.UndefinedTable as exc: + # Fail fast with an actionable message instead of a generic per-execution + # stack trace: the table is missing because migration 0012 hasn't run. + # NOT swallowed to a "table missing → proceed" fallback — that would + # reopen the double-orchestration window this guard exists to close. + raise RuntimeError( + "pg_orchestration_claim table is missing — migration " + "0012_pgorchestrationclaim has not been applied. Run backend " + "migrations before enabling PG transport (pg_queue_enabled)." + ) from exc + + +def release_orchestration_claim(execution_id: str) -> None: + """Release the orchestration claim so a redelivery/retry can re-orchestrate. + + Called from the orchestrator's failure path: the claim is taken before the + work, so without this a transient first-attempt failure would leave the claim + committed and make every redelivery permanently no-op (a silently-lost + execution). Deleting it on failure restores the retry/redelivery path. A + successful orchestration does NOT call this — its claim persists as the + post-completion tombstone. Safe when no row exists (deletes nothing). + """ + with _cursor() as cur: + cur.execute( + f"DELETE FROM {qualified('pg_orchestration_claim')} WHERE execution_id = %s", + (execution_id,), + ) + + def _dispatch_pg( task_name: str, *, diff --git a/workers/queue_backend/pg_queue/schema.py b/workers/queue_backend/pg_queue/schema.py index ddb4b69b9c..50364cec7c 100644 --- a/workers/queue_backend/pg_queue/schema.py +++ b/workers/queue_backend/pg_queue/schema.py @@ -1,8 +1,9 @@ """Schema-qualified table names for the bespoke PG queue. The queue's tables (``pg_queue_message``, ``pg_task_result``, -``pg_barrier_state``, ``pg_batch_dedup``, ``pg_orchestrator_lock``, -``pg_periodic_schedule``) live in the schema the backend manages +``pg_barrier_state``, ``pg_batch_dedup``, ``pg_orchestration_claim``, +``pg_orchestrator_lock``, ``pg_periodic_schedule``) live in the schema the +backend manages (``DB_SCHEMA`` — ``unstract`` on-prem, a per-developer schema such as ``ali`` in cloud dev). @@ -47,6 +48,7 @@ "pg_task_result", "pg_barrier_state", "pg_batch_dedup", + "pg_orchestration_claim", "pg_orchestrator_lock", "pg_periodic_schedule", } diff --git a/workers/tests/test_orchestration_idempotency.py b/workers/tests/test_orchestration_idempotency.py new file mode 100644 index 0000000000..44f1f10470 --- /dev/null +++ b/workers/tests/test_orchestration_idempotency.py @@ -0,0 +1,289 @@ +"""UN-3671: orchestration idempotency claim — the double-orchestration guard for +``async_execute_bin`` before replica scale-out. + +Two layers: + +1. **Gate + short-circuit** (no DB): the transport-gated + ``_should_skip_duplicate_orchestration`` helper and the + ``async_execute_bin_general`` no-op wiring, with the claim primitive mocked. +2. **Claim primitive** (real Postgres): ``try_claim_orchestration`` / + ``release_orchestration_claim`` against the ``pg_orchestration_claim`` table — + skips if unreachable/unmigrated. +""" + +from __future__ import annotations + +import logging +import os +import threading +from unittest.mock import patch + +import psycopg2 +import pytest +from general import tasks +from general.tasks import _should_skip_duplicate_orchestration +from queue_backend import pg_barrier +from queue_backend.pg_barrier import ( + release_orchestration_claim, + try_claim_orchestration, +) +from queue_backend.pg_queue.connection import create_pg_connection + +# --- Layer 1: transport-gated gate (no DB) --- + + +class TestOrchestrationGate: + """``_should_skip_duplicate_orchestration`` — Celery never claims; on PG the + first delivery proceeds and a duplicate skips. + """ + + def test_celery_transport_never_claims(self): + # The whole guard is PG-only: on Celery it must not even consult the claim + # (proves the Celery path is behaviorally untouched). + with patch("general.tasks.try_claim_orchestration") as claim: + assert _should_skip_duplicate_orchestration("e1", "celery") is False + claim.assert_not_called() + + def test_pg_first_delivery_proceeds(self): + with patch("general.tasks.try_claim_orchestration", return_value=True) as claim: + assert _should_skip_duplicate_orchestration("e1", "pg_queue") is False + claim.assert_called_once_with("e1") + + def test_pg_duplicate_delivery_skips(self): + with patch("general.tasks.try_claim_orchestration", return_value=False): + assert _should_skip_duplicate_orchestration("e1", "pg_queue") is True + + def test_claim_error_propagates(self): + # A DB error in the claim must PROPAGATE, not be swallowed — a swallow-and- + # proceed would double-orchestrate, a swallow-and-skip would silently drop a + # legitimate first delivery. Pin the no-try/except contract. + with patch( + "general.tasks.try_claim_orchestration", + side_effect=psycopg2.OperationalError("db down"), + ): + with pytest.raises(psycopg2.OperationalError): + _should_skip_duplicate_orchestration("e1", "pg_queue") + + def test_skip_on_retry_logs_error_with_errorid(self, caplog): + # A skip when retries > 0 is a SUPPRESSED retry (a prior attempt claimed + # and crashed before releasing), not a benign duplicate — log it at ERROR + # with an errorId so alerting can distinguish the two. + with patch("general.tasks.try_claim_orchestration", return_value=False): + with caplog.at_level(logging.ERROR, logger="general.tasks"): + assert _should_skip_duplicate_orchestration("e1", "pg_queue", 2) is True + assert "ORCH_CLAIM_SUPPRESSED_RETRY" in caplog.text + + +class TestOrchestratorShortCircuit: + """``async_execute_bin_general`` no-ops on a duplicate BEFORE any setup / arm / + dispatch. + """ + + def test_duplicate_returns_skip_without_setup(self): + # Exercise the REAL gate end-to-end: a PG duplicate (try_claim_orchestration + # returns False) must short-circuit to the skip result before any setup / + # barrier arm / dispatch. Reaching the skip dict is itself proof setup was + # never called (it runs after the gate). + # + # Belt-and-braces: the worker *bootstrap* loads async_execute_bin_general + # under a bare ``tasks`` module (a sys.path quirk) distinct from + # ``general.tasks`` — that bootstrap doesn't run under pytest, so here + # ``fn.__globals__`` IS ``general.tasks.__dict__`` and a plain + # ``patch("general.tasks...")`` would also work. Patching the task's own + # globals is simply robust to either loading. + fn = tasks.async_execute_bin_general + while hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + with patch.dict(fn.__globals__, {"try_claim_orchestration": lambda _eid: False}): + out = tasks.async_execute_bin_general( + schema_name="org", + workflow_id="wf", + execution_id="e-dup", + hash_values_of_files={}, + transport="pg_queue", + ) + assert out == { + "status": "skipped_duplicate_orchestration", + "execution_id": "e-dup", + "workflow_id": "wf", + } + + @staticmethod + def _task_globals(): + fn = tasks.async_execute_bin_general + while hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + return fn.__globals__ + + def test_no_release_when_claim_never_acquired(self): + # G2: if the claim's own INSERT raises, the failure path must NOT call + # release — nothing was acquired, and a spurious DELETE would reconnect just + # to delete nothing and muddy the log. + released: list[str] = [] + + def _boom(_eid): + raise psycopg2.OperationalError("db down") + + with patch.dict( + self._task_globals(), + { + "try_claim_orchestration": _boom, + "release_orchestration_claim": lambda eid: released.append(eid), + }, + ): + with pytest.raises(psycopg2.OperationalError): + tasks.async_execute_bin_general( + schema_name="org", + workflow_id="wf", + execution_id="e-x", + hash_values_of_files={}, + transport="pg_queue", + ) + assert released == [] # claim never acquired → nothing released + + def test_release_called_when_claimed_and_work_fails(self): + # The claim was won (gate proceeds) but the work fails → release it so the + # redelivery/retry can re-orchestrate. + released: list[str] = [] + with patch.dict( + self._task_globals(), + { + "try_claim_orchestration": lambda _eid: True, # won the claim + "release_orchestration_claim": lambda eid: released.append(eid), + }, + ): + with patch.object( + tasks.WorkerExecutionContext, + "setup_execution_context", + side_effect=RuntimeError("setup boom"), + ): + with pytest.raises(RuntimeError): + tasks.async_execute_bin_general( + schema_name="org", + workflow_id="wf", + execution_id="e-y", + hash_values_of_files={}, + transport="pg_queue", + ) + assert released == ["e-y"] # claimed then failed → released + + +# --- Layer 2: claim primitive against real Postgres --- + + +@pytest.fixture +def claim_db(): + """Inject a real autocommit connection into pg_barrier's thread-local so the + claim primitives write to the real ``pg_orchestration_claim`` table. + """ + os.environ.setdefault("TEST_DB_HOST", "127.0.0.1") + try: + conn = create_pg_connection(env_prefix="TEST_DB_") + except psycopg2.OperationalError as exc: + pytest.skip(f"Postgres not reachable: {exc}") + conn.autocommit = True + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('pg_orchestration_claim')") + if cur.fetchone()[0] is None: + conn.close() + pytest.skip( + "pg_orchestration_claim migration not applied (run backend migrate)" + ) + cur.execute("DELETE FROM pg_orchestration_claim") + pg_barrier._local.conn = conn + yield conn + with conn.cursor() as cur: + cur.execute("DELETE FROM pg_orchestration_claim") + conn.close() + pg_barrier._local.conn = None + + +def _claim_count(conn, execution_id): + with conn.cursor() as cur: + cur.execute( + "SELECT count(*) FROM pg_orchestration_claim WHERE execution_id = %s", + (execution_id,), + ) + return cur.fetchone()[0] + + +@pytest.mark.integration +class TestClaimOrchestration: + def test_first_claim_wins(self, claim_db): + assert try_claim_orchestration("exec-1") is True + + def test_duplicate_claim_loses(self, claim_db): + assert try_claim_orchestration("exec-1") is True + assert try_claim_orchestration("exec-1") is False # redelivery / sibling + + def test_distinct_executions_are_independent(self, claim_db): + assert try_claim_orchestration("exec-1") is True + assert try_claim_orchestration("exec-2") is True # keyed on execution_id + + def test_claim_persists_as_tombstone(self, claim_db): + # NOT cleared at finalise (unlike per-batch markers): a post-completion + # redelivery still finds it, so a finished execution can't be re-orchestrated. + assert try_claim_orchestration("exec-keep") is True + assert _claim_count(claim_db, "exec-keep") == 1 + assert try_claim_orchestration("exec-keep") is False + + def test_release_allows_reclaim(self, claim_db): + # The failure-path release frees the slot so a redelivery/retry can + # re-orchestrate (this is what stops a transient failure permanently + # no-oping every redelivery). + assert try_claim_orchestration("exec-rel") is True + assert try_claim_orchestration("exec-rel") is False # held + release_orchestration_claim("exec-rel") + assert _claim_count(claim_db, "exec-rel") == 0 + assert try_claim_orchestration("exec-rel") is True # reclaimable after release + + def test_release_is_safe_when_absent(self, claim_db): + release_orchestration_claim("never-claimed") # no row → no error + assert _claim_count(claim_db, "never-claimed") == 0 + + def test_concurrent_claims_resolve_to_exactly_one_winner(self, claim_db): + # The real reason this feature exists: N replicas racing the SAME + # orchestration must resolve to exactly one True. Force the contended + # ON CONFLICT path — pre-build N connections and release every claim at + # once via a threading.Barrier — and repeat over distinct executions so a + # non-atomic check-then-insert can't pass by luck. Each thread uses its own + # connection (a libpq conn is not thread-safe across threads). + n = 8 + conns = [create_pg_connection(env_prefix="TEST_DB_") for _ in range(n)] + for c in conns: + c.autocommit = True + try: + for trial in range(5): + execution_id = f"exec-race-{trial}" + gate = threading.Barrier(n) + results: list[bool] = [] + lock = threading.Lock() + + # Bind loop vars as defaults (ruff B023): threads are created and + # joined within the iteration, but defaults make the capture explicit. + def run(conn, eid=execution_id, gate=gate, results=results, lock=lock): + pg_barrier._local.conn = conn + try: + gate.wait() # align all claims at the same instant + won = try_claim_orchestration(eid) + with lock: + results.append(won) + finally: + pg_barrier._local.conn = None + + threads = [threading.Thread(target=run, args=(c,)) for c in conns] + for t in threads: + t.start() + for t in threads: + t.join() + + assert sum(results) == 1, f"trial {trial}: {sum(results)} winners" + assert _claim_count(claim_db, execution_id) == 1 + finally: + for c in conns: + c.close() + pg_barrier._local.conn = None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index bffc2cce3f..c39ba21cf6 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -30,6 +30,7 @@ barrier_pg_decr_and_check, claim_batch, run_batch_with_barrier, + try_claim_orchestration, ) from queue_backend.pg_queue.connection import create_pg_connection from queue_backend.routing import QueueBackend @@ -468,6 +469,74 @@ def test_create_pg_connection_is_non_autocommit(): conn.close() +class TestClaimOrchestrationRetry: + """``try_claim_orchestration`` is a RETURNING claim, so — like the decrement, + and unlike the idempotent pre-dispatch write — it self-heals ONLY the + provably-safe case: an execute-phase failure on a *cached* connection (idle + reap; the INSERT never committed, so the retry's win/lost answer is + authoritative). A commit-phase failure is ambiguous and a fresh-conn failure + is a real error — neither is retried, so a real winner is never flipped to a + loser (which would strand the execution). Guards UN-3671's PR-review fix. + """ + + @pytest.fixture + def sleeps(self, monkeypatch): + calls: list[float] = [] + monkeypatch.setattr(pg_barrier.time, "sleep", calls.append) + return calls + + def test_execute_phase_reaped_cached_conn_retries_once( + self, _clean_local, monkeypatch, caplog, sleeps + ): + dead = _FakeConn( + execute_error=psycopg2.OperationalError("server closed the connection") + ) + healthy = _FakeConn() + pg_barrier._local.conn = dead # cached → "reused" + monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: healthy) + + with caplog.at_level("WARNING"): + try_claim_orchestration("exec-1") + + assert dead.executes == 1 and healthy.executes == 1 # exactly one extra try + assert dead.commits == 0 # the reaped attempt never committed + assert dead.closed is True # stale conn discarded + assert healthy.commits == 1 # committed exactly once, on the retry + assert pg_barrier._local.conn is healthy + assert sleeps == [pg_barrier._BARRIER_RETRY_BACKOFF_SECONDS] + assert "execute failed on a cached connection" in caplog.text + + def test_commit_phase_failure_is_not_retried(self, _clean_local, monkeypatch, sleeps): + # Ambiguous commit (the server may have committed) → NOT retried, or a real + # winner could be flipped to a loser. Propagates; no reconnect. + conn = _FakeConn( + commit_error=psycopg2.OperationalError("server closed during commit") + ) + pg_barrier._local.conn = conn + monkeypatch.setattr( + pg_barrier, + "create_pg_connection", + lambda **_k: pytest.fail("must not reconnect on a commit-phase failure"), + ) + with pytest.raises(psycopg2.OperationalError): + try_claim_orchestration("exec-1") + assert conn.executes == 1 and conn.commits == 1 # tried once, no retry + assert sleeps == [] # no backoff → no retry + + def test_fresh_conn_execute_failure_is_not_retried( + self, _clean_local, monkeypatch, sleeps + ): + # A freshly-created connection failing is a real DB error, not an idle reap + # (reused is False when _local.conn starts empty) → not retried. + dead = _FakeConn(execute_error=psycopg2.OperationalError("connection refused")) + pg_barrier._local.conn = None # → reused False + monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: dead) + with pytest.raises(psycopg2.OperationalError): + try_claim_orchestration("exec-1") + assert dead.executes == 1 # no retry + assert sleeps == [] + + # --- Layer 2: enqueue + link/abort with a real injected connection --- From 5b75eb7e6fe6e745578b04aaaffeca7eab5e1236 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:37:23 +0530 Subject: [PATCH 57/65] =?UTF-8?q?UN-3679=20[FIX]=20PG=20reaper=20=E2=80=94?= =?UTF-8?q?=20sweep=20pg=5Forchestration=5Fclaim=20(GC=20+=20crash-window?= =?UTF-8?q?=20recovery)=20(#2142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3679 [FIX] PG reaper — sweep pg_orchestration_claim (GC + crash-window recovery) Closes the two recovery gaps UN-3671 deferred (its review's blocker 2 + the no-GC tombstone); the pg_queue_enabled gate can ramp once this lands. The orchestration claim is taken BEFORE the barrier is armed, so — unlike pg_batch_dedup, whose barrier always exists — a hard crash in the claim->arm window leaves a claim with no pg_barrier_state row that the barrier sweep can't see, and a successful claim's tombstone (kept to block post-completion re-orchestration) has no natural GC (the live sweep_orphan_dedup only touches pg_batch_dedup). Add sweep_orphan_claims to the reaper's retention sweep (cadence-gated, its own _run_sweep, leader-only). For each claim with NO matching pg_barrier_state row older than the stuck-timeout: - terminal execution -> GC the tombstone (delete), - non-terminal -> mark it ERROR (mark_execution_error, +cascade) then delete — the crash-window recovery. A claim younger than the stuck-timeout, or one whose barrier is armed (a live run), is left alone; the mark re-checks still-orphan right before firing so a slow-but-live orchestration that just armed isn't flagged ERROR; per-row best-effort so one failure (API down, unreadable status, unconfirmed mark) leaves that row for the next sweep and never blocks the others. Correctness: a crash-window strand's message is acked on the first skip-redelivery, so by the stuck-timeout no message exists — the reaper can mark+delete without re-orchestration risk. Schema: add organization_id to pg_orchestration_claim (model + migration 0013) — the reaper needs it for the org-scoped status/mark API (the row had only execution_id). try_claim_orchestration now stamps it; the gate passes schema_name. Kept as TextField (no FK), consistent with every PG-queue table (PgQueueMessage.org_id / PgBarrierState.organization_id): the queue is self-contained infra written via raw SQL, the value is the org identifier string (matches organization.organization_id, not the BIGINT PK), and "" is a valid "no org" sentinel an FK can't reference. Tests: 7 real-PG claim-sweep cases (GC-terminal, recover-crash-window, leave-young, leave-armed-barrier, unconfirmed-mark-leaves, no-org-leaves, one-failure-isolation) + a _maybe_sweep wiring assertion; existing claim/gate tests updated for the org arg. Unit lane 1142; touched integration green. Celery path untouched (claim/gate PG-transport-gated; sweep leader-only, PG-only). Co-Authored-By: Claude Fable 5 * UN-3679 [FIX] Address PR review — UndefinedColumn guard, systemic-failure signal, delete-race rowcount, typing/doc + race tests PR Review Toolkit (6 agents) + Greptile on PR #2142. No blockers. - MEDIUM (deploy) — try_claim_orchestration's guard now also catches psycopg2.errors.UndefinedColumn: a 0012-but-not-0013 deploy has the table but not organization_id, so the INSERT raised an uncaught UndefinedColumn (the generic per-execution trace the guard exists to prevent). Message updated to name both migrations. - MEDIUM (observability) — sweep_orphan_claims now tracks a `failed` counter and RAISES when a non-empty sweep accomplished nothing because every row raised (systemic: API down / bad migration), so _run_sweep records it on the consecutive-failure streak instead of a clean return resetting it. A partial failure is a warning. Mirrors recover_expired_barriers' escalation. - MEDIUM (race/miscount) — _delete_orphan_claim now returns cur.rowcount; both the GC and recovery branches treat a 0-row delete (a concurrent release+ re-claim or barrier arm won the window) as a benign skip with a warning, so `removed` never over-reports. (Both reviewers flagged this.) - LOW — documented the ack-safety assumption on the crash-window delete (+ the accepted residual: a whole-fleet outage longer than the stuck-timeout); _recover_one_claim return typed Literal["recovered","gc"] | None with shared module constants; the GC docstring now defers to ExecutionStatus.is_completed (COMPLETED / STOPPED / ERROR) as the single source of truth. - NIT (declined) — keeping _orphan_claim_where(alias) as a function (explicit correlation) rather than a _STRANDED_PREDICATE-style constant. Tests: schema-behind guard (UndefinedTable + UndefinedColumn → actionable error); all-rows-failing → systemic raise; re-check race (barrier armed between SELECT and mark → not marked); delete re-guard (re-claim between SELECT and DELETE → 0-row delete, fresh claim survives). Unit lane 1144; integration green. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- ...13_pgorchestrationclaim_organization_id.py | 17 + backend/pg_queue/models.py | 37 ++- workers/general/tasks.py | 9 +- workers/queue_backend/pg_barrier.py | 39 +-- workers/queue_backend/pg_queue/reaper.py | 291 +++++++++++++++++- .../tests/test_orchestration_idempotency.py | 47 +-- workers/tests/test_pg_barrier.py | 19 +- workers/tests/test_pg_reaper.py | 216 ++++++++++++- 8 files changed, 609 insertions(+), 66 deletions(-) create mode 100644 backend/pg_queue/migrations/0013_pgorchestrationclaim_organization_id.py diff --git a/backend/pg_queue/migrations/0013_pgorchestrationclaim_organization_id.py b/backend/pg_queue/migrations/0013_pgorchestrationclaim_organization_id.py new file mode 100644 index 0000000000..73330820ca --- /dev/null +++ b/backend/pg_queue/migrations/0013_pgorchestrationclaim_organization_id.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.30 on 2026-07-02 14:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pg_queue", "0012_pgorchestrationclaim"), + ] + + operations = [ + migrations.AddField( + model_name="pgorchestrationclaim", + name="organization_id", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/backend/pg_queue/models.py b/backend/pg_queue/models.py index 54f0b96184..2146f3868e 100644 --- a/backend/pg_queue/models.py +++ b/backend/pg_queue/models.py @@ -368,17 +368,19 @@ class PgOrchestrationClaim(models.Model): completion (when the barrier row is already gone) can't re-orchestrate a finished execution. - **Recovery gaps (follow-up — the gate stays off until then):** unlike + **Recovery + GC (the reaper's ``sweep_orphan_claims``, UN-3679).** Unlike ``pg_batch_dedup`` — whose barrier is always armed before any batch runs, so the reaper always has a ``pg_barrier_state`` handle — this claim is taken - BEFORE the barrier is armed. A hard crash in the claim→arm window leaves a - claim with no barrier row, invisible to the reaper's barrier sweep. And the - live ``sweep_orphan_dedup`` reclaims only ``pg_batch_dedup`` (it never touches - this table), so a successful claim's tombstone currently has NO GC and lives - forever. Both are addressed by extending the reaper to sweep this table by - ``claimed_at`` age (GC terminal/aged claims; mark a non-terminal orphan-claim - execution ERROR). Bounded meanwhile: one row per execution, and the gate is - off. + BEFORE the barrier is armed, so a hard crash in the claim→arm window leaves a + claim with no barrier row that the barrier sweep can't see, and a successful + claim's tombstone has no natural GC. The reaper sweeps this table for claims + with **no matching ``pg_barrier_state`` row** older than the stuck-timeout: a + terminal execution → delete the claim (GC of a completed/failed tombstone); a + non-terminal execution → mark it ERROR (crash-window recovery) via the + org-scoped API, then delete. A claim younger than the stuck-timeout, or one + whose barrier is armed (live run), is left alone. This is why + ``organization_id`` is stamped below — the reaper needs it for the org-scoped + status/mark API, exactly like ``pg_barrier_state``. Only written on the PG transport (``is_pg_transport``) — the Celery path never touches it. Managed=True / generated migration, extension-free — same posture @@ -389,11 +391,20 @@ class PgOrchestrationClaim(models.Model): # single-winner invariant (the worker SQL can't import this model). Tombstone: # deliberately never deleted at barrier finalise (there is no orchestration- # claim analog of clear_execution_batches) — that is what blocks a - # post-completion redelivery from re-orchestrating. A future maintainer must - # NOT add a finalise-time DELETE here. + # post-completion redelivery from re-orchestrating. Only the reaper's + # sweep_orphan_claims deletes it (GC terminal / recover crash-window); a future + # maintainer must NOT add a finalise-time DELETE here. execution_id = models.TextField(primary_key=True) - # Observability/debugging today (when the orchestration was claimed). The - # planned reaper sweep would reclaim by this column's age and add an index then. + # Owning org, stamped at claim time. The reaper needs it to call the org-scoped + # internal status/mark API when recovering or GC'ing an orphan claim — it has + # only the execution_id off the row otherwise. "" = unknown (the reaper then + # can't mark and skips). Same no-NULL-text convention as PgBarrierState. + organization_id = models.TextField(blank=True, default="") + # When the orchestration was claimed. The reaper's sweep_orphan_claims gates on + # this column's age (only claims older than the stuck-timeout are swept, so a + # just-claimed live orchestration that hasn't armed its barrier is left alone). + # Deliberately UNINDEXED — a near-empty table swept at the 5-min retention + # cadence is a cheap seq scan; add an index if it ever grows. claimed_at = models.DateTimeField(default=timezone.now) class Meta: diff --git a/workers/general/tasks.py b/workers/general/tasks.py index 3a4d772db1..e8cae96190 100644 --- a/workers/general/tasks.py +++ b/workers/general/tasks.py @@ -143,12 +143,15 @@ def _log_batch_creation_statistics( def _should_skip_duplicate_orchestration( - execution_id: str, transport: str, retries: int = 0 + execution_id: str, organization_id: str, transport: str, retries: int = 0 ) -> bool: """PG-only: claim the execution's orchestration slot; ``True`` iff THIS delivery lost the claim (a duplicate / redelivered orchestration) and must no-op. + ``organization_id`` (the org schema_name) is stamped onto the claim row so the + reaper can call the org-scoped API when it recovers or GCs an orphan claim. + On the PG queue the orchestration task can be redelivered — it runs longer than the consumer's visibility timeout and a sibling replica re-claims the message — and re-running it re-arms the barrier (resets ``remaining`` and @@ -170,7 +173,7 @@ def _should_skip_duplicate_orchestration( """ if not is_pg_transport(transport): return False - if try_claim_orchestration(execution_id): + if try_claim_orchestration(execution_id, organization_id): return False # first delivery — this replica owns the orchestration if retries > 0: logger.error( @@ -256,7 +259,7 @@ def async_execute_bin_general( # or batch dispatch — so exactly one delivery orchestrates the # execution even with replicas > 1. No-op on Celery. if _should_skip_duplicate_orchestration( - execution_id, transport, self.request.retries + execution_id, schema_name, transport, self.request.retries ): return { "status": "skipped_duplicate_orchestration", diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 289fc8b631..65895c31f6 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -306,7 +306,7 @@ def clear_execution_batches(execution_id: str) -> int: return cur.rowcount -def try_claim_orchestration(execution_id: str) -> bool: +def try_claim_orchestration(execution_id: str, organization_id: str) -> bool: """Try to claim the orchestration slot for ``execution_id`` — the execution-level idempotency gate mirroring :func:`claim_batch`, one level up. The ``try_`` prefix signals the return is "did I win": ``True`` = claimed @@ -315,7 +315,9 @@ def try_claim_orchestration(execution_id: str) -> bool: Atomically inserts the single ``pg_orchestration_claim`` row via ``INSERT … ON CONFLICT DO NOTHING RETURNING`` (a single statement, so two replicas racing the same orchestration resolve to exactly one ``True`` — the - row's existence is the token). + row's existence is the token). ``organization_id`` is stamped on the row so the + reaper's ``sweep_orphan_claims`` can call the org-scoped status/mark API when it + recovers or GCs the claim (``""`` if the caller has no org). Claimed BEFORE the orchestration work (arm barrier / dispatch batches). The claim is **released on failure** (see :func:`release_orchestration_claim`, @@ -324,16 +326,13 @@ def try_claim_orchestration(execution_id: str) -> bool: tombstone that blocks a redelivery AFTER the execution completes (when the barrier row is already gone) from re-arming and re-dispatching. - **Recovery gaps (tracked as follow-up — flag stays off until then):** unlike + **Recovery + GC (the reaper's ``sweep_orphan_claims``).** Unlike :func:`claim_batch`, whose barrier is always armed before any batch runs (so the reaper always has a ``pg_barrier_state`` handle), this claim is taken BEFORE the barrier is armed. A hard crash (SIGKILL / eviction) in the - claim→arm window leaves a claim with no barrier row, which the reaper's - barrier sweep cannot see. And ``sweep_orphan_dedup`` reclaims only - ``pg_batch_dedup`` — nothing GCs ``pg_orchestration_claim`` yet, so a - successful claim's tombstone currently lives forever. Both are addressed by - extending the reaper to sweep this table (GC terminal/aged claims; mark a - non-terminal orphan-claim execution ERROR). + claim→arm window leaves a claim with no barrier row; the reaper sweeps such + orphan claims older than the stuck-timeout — GC'ing a terminal execution's + tombstone and marking a non-terminal (crash-window) execution ERROR. PG path only — the caller gates on the transport (Celery has no redelivery, so no double-orchestration to guard). @@ -361,10 +360,11 @@ def try_claim_orchestration(execution_id: str) -> bool: with conn.cursor() as cur: cur.execute( f"INSERT INTO {qualified('pg_orchestration_claim')} " - "(execution_id, claimed_at) VALUES (%s, now()) " + "(execution_id, organization_id, claimed_at) " + "VALUES (%s, %s, now()) " "ON CONFLICT (execution_id) DO NOTHING " "RETURNING execution_id", - (execution_id,), + (execution_id, organization_id), ) won = cur.fetchone() is not None except Exception as exc: @@ -403,15 +403,18 @@ def try_claim_orchestration(execution_id: str) -> bool: return won # Unreachable: the loop either returns or raises. raise AssertionError("try_claim_orchestration loop fell through") - except psycopg2.errors.UndefinedTable as exc: + except (psycopg2.errors.UndefinedTable, psycopg2.errors.UndefinedColumn) as exc: # Fail fast with an actionable message instead of a generic per-execution - # stack trace: the table is missing because migration 0012 hasn't run. - # NOT swallowed to a "table missing → proceed" fallback — that would - # reopen the double-orchestration window this guard exists to close. + # stack trace when the schema is behind: UndefinedTable (0012 missing) OR + # UndefinedColumn — a 0012-but-not-0013 deploy has the table but not + # organization_id, so the INSERT above raises UndefinedColumn (a sibling + # SQLSTATE). NOT swallowed to a "proceed" fallback — that would reopen the + # double-orchestration window this guard exists to close. raise RuntimeError( - "pg_orchestration_claim table is missing — migration " - "0012_pgorchestrationclaim has not been applied. Run backend " - "migrations before enabling PG transport (pg_queue_enabled)." + "pg_orchestration_claim schema is out of date — migrations " + "0012_pgorchestrationclaim / 0013_pgorchestrationclaim_organization_id " + "have not been fully applied. Run backend migrations before enabling " + "PG transport (pg_queue_enabled)." ) from exc diff --git a/workers/queue_backend/pg_queue/reaper.py b/workers/queue_backend/pg_queue/reaper.py index 96f18eb8ff..524520ac94 100644 --- a/workers/queue_backend/pg_queue/reaper.py +++ b/workers/queue_backend/pg_queue/reaper.py @@ -26,6 +26,15 @@ a crash after the final decrement but before the callback enqueues) would bottom out silently at the ~6h barrier expiry. +**Orchestration-claim GC + recovery (UN-3679).** The orchestration idempotency +claim (``pg_orchestration_claim``) is taken BEFORE the barrier is armed, so a +crash in the claim→arm window leaves a claim with no barrier row — invisible to +the barrier sweep above — and a successful claim's tombstone has no natural GC. +The retention sweep's :func:`sweep_orphan_claims` closes both: for a claim with no +matching barrier row older than the stuck-timeout, it GC's a terminal execution's +tombstone and marks a non-terminal (crash-window) execution ERROR (org-scoped, +same best-effort per-row posture). + **Deferred (follow-up).** *Callback re-fire* for the ``remaining==0`` strand (heal → COMPLETED instead of ERROR) needs the ``callback_descriptor`` stored on the barrier row; until then those strands are marked ERROR. Per-stage re-enqueue @@ -52,7 +61,7 @@ import threading import time from collections.abc import Callable -from typing import TYPE_CHECKING, NamedTuple, Protocol, TypeVar +from typing import TYPE_CHECKING, Final, Literal, NamedTuple, Protocol, TypeVar from unstract.core.data_models import ExecutionStatus @@ -61,6 +70,7 @@ from .leader_election import LeaderLease, default_worker_id from .liveness import LivenessServer as _BaseLivenessServer from .pg_scheduler import dispatch_due_schedules +from .recovery import mark_execution_error from .schema import qualified if TYPE_CHECKING: @@ -93,6 +103,14 @@ _N = TypeVar("_N", int, float) +# Orphan-claim recovery outcomes (a closed domain — a typo in a returned literal +# or a caller comparison would otherwise silently fall through to the skip path +# and mis-count the sweep). Shared by _recover_one_claim (producer) and +# sweep_orphan_claims (consumer). +_CLAIM_RECOVERED: Final = "recovered" # execution marked ERROR (crash-window) +_CLAIM_GC: Final = "gc" # terminal execution's tombstone deleted +_ClaimOutcome = Literal["recovered", "gc"] + class LeaderLeaseLike(Protocol): """Structural contract :class:`PgReaper` needs from a lease. @@ -543,6 +561,261 @@ def recover_expired_barriers( return recovered +def _orphan_claim_where(claim_alias: str) -> str: + """WHERE clause selecting orphan ``pg_orchestration_claim`` rows. + + A claim is an orphan when it has NO matching ``pg_barrier_state`` row (never + armed — the crash-window strand — OR armed-then-finalised — a completed/failed + tombstone) AND is older than the stuck-timeout (so a just-claimed live + orchestration that hasn't armed its barrier yet is left alone). Binds one + ``%s`` — the stuck-timeout seconds. Defined once so the sweep SELECT, the + pre-mark re-check, and the cleanup DELETE can't drift (like + :data:`_STRANDED_PREDICATE` for barriers). + """ + return ( + f"{claim_alias}.claimed_at < now() - make_interval(secs => %s) " + f"AND NOT EXISTS (SELECT 1 FROM {qualified('pg_barrier_state')} b " + f"WHERE b.execution_id = {claim_alias}.execution_id)" + ) + + +def _claim_still_orphan( + conn: PgConnection, execution_id: str, stuck_timeout_seconds: int +) -> bool: + """True iff the claim is STILL an orphan (no barrier, still old) right now. + + Re-checked immediately before the ERROR mark: between the sweep's SELECT and + the mark, a slow-but-live orchestration could finally arm its barrier (making + it the reaper's barrier-recovery concern, not a crash-window strand), or the + claim could have been released and re-claimed with a fresh ``claimed_at``. In + either case its live run must NOT be marked ERROR. + """ + with conn.cursor() as cur: + cur.execute( + f"SELECT 1 FROM {qualified('pg_orchestration_claim')} AS c " + f"WHERE c.execution_id = %s AND {_orphan_claim_where('c')}", + (execution_id, stuck_timeout_seconds), + ) + found = cur.fetchone() is not None + conn.commit() + return found + + +def _delete_orphan_claim( + conn: PgConnection, execution_id: str, stuck_timeout_seconds: int +) -> int: + """Delete a claim, re-guarded on still-orphan-and-old so a concurrent re-claim + (release + redelivery inserting a fresh ``claimed_at``) or a freshly-armed + barrier is never torn out from under a live run. Returns the number of rows + deleted (0 when the WHERE no longer matched — a concurrent re-claim / barrier + arm won the race, so the caller must not count or log a removal). + """ + with conn.cursor() as cur: + cur.execute( + f"DELETE FROM {qualified('pg_orchestration_claim')} AS c " + f"WHERE c.execution_id = %s AND {_orphan_claim_where('c')}", + (execution_id, stuck_timeout_seconds), + ) + deleted = cur.rowcount + conn.commit() + return deleted + + +def _recover_one_claim( + conn: PgConnection, + api_client: InternalAPIClient, + execution_id: str, + organization_id: str, + stuck_timeout_seconds: int, +) -> _ClaimOutcome | None: + """Recover or GC one orphan claim; return :data:`_CLAIM_RECOVERED` (execution + marked ERROR — a crash-window recovery), :data:`_CLAIM_GC` (a terminal + execution's tombstone deleted), or ``None`` (a benign skip). The claim row is + deleted on GC and on a confirmed recovery; it is LEFT for the next sweep on any + unconfirmed step (no org, unreadable status, re-armed during recovery, an + unconfirmed mark, or a 0-row delete lost to a concurrent re-claim) so nothing + is lost and nothing is mis-counted. + + A terminal execution (per :meth:`ExecutionStatus.is_completed` — COMPLETED / + STOPPED / ERROR, the single source of truth) → GC the tombstone. A non-terminal + one is the claim→arm crash window (the orchestrator committed the claim then + died before arming the barrier; the reaper's barrier sweep can't see it because + there is no barrier row) → mark it ERROR so it reaches a terminal state instead + of stranding EXECUTING forever, then delete the claim. + """ + if not organization_id: + logger.error( + "Reaper: orphan orchestration claim for execution %s has NO " + "organization_id — cannot call the org-scoped status/mark API; leaving " + "the row. A claim was written without an org — investigate.", + execution_id, + ) + return None + + status = _execution_status(api_client, execution_id, organization_id) + if status is None: + logger.warning( + "Reaper: status read for orphan-claim execution %s returned no status " + "— leaving the claim for the next sweep.", + execution_id, + ) + return None + + if ExecutionStatus.is_completed(status): + # Terminal: the claim is a tombstone with no live run behind it — GC it. + # (A terminal execution's orchestration message was acked on completion or + # on the first skip-redelivery, so there is nothing left to re-orchestrate + # once the row is gone.) A 0-row delete means a concurrent release+re-claim + # replaced the row in the window since the SELECT — leave the fresh one. + if not _delete_orphan_claim(conn, execution_id, stuck_timeout_seconds): + logger.warning( + "Reaper: orphan-claim execution %s was re-claimed during GC — " + "leaving the fresh claim (its new run owns it).", + execution_id, + ) + return None + logger.info( + "Reaper: GC'd orphan orchestration claim for terminal execution %s (%s).", + execution_id, + status, + ) + return _CLAIM_GC + + # Non-terminal → crash-window strand. Re-check it's STILL an orphan right before + # marking, so a slow orchestration that just armed its barrier (or a fresh + # re-claim) isn't flagged ERROR while live. + if not _claim_still_orphan(conn, execution_id, stuck_timeout_seconds): + logger.warning( + "Reaper: orphan-claim execution %s armed a barrier or was re-claimed " + "during recovery — leaving it (its live run owns the claim).", + execution_id, + ) + return None + + if not mark_execution_error( + api_client, + execution_id, + organization_id, + error_message=( + "[reaper-recovery] Execution stranded: the orchestration claimed its " + "slot but the barrier was never armed (crash before dispatch)." + ), + ): + # Unconfirmed mark — do NOT delete the claim (it is the only recovery + # handle); leave it for the next sweep to retry. + return None + + # Safe to delete the tombstone now that the execution is terminal: the same + # ack argument as the GC branch holds (the message was acked on the first + # skip-redelivery). The re-guarded DELETE additionally protects the rare race + # where the claim was re-armed between the mark and here — a 0-row delete then + # leaves the fresh claim. (Residual: if the ENTIRE worker fleet were down + # longer than the stuck-timeout so no consumer ever hit the skip-and-ack path, + # the message could still be un-acked and a later redelivery would re-win the + # claim — an accepted tradeoff for a catastrophic multi-hour outage.) + if not _delete_orphan_claim(conn, execution_id, stuck_timeout_seconds): + logger.warning( + "Reaper: orphan-claim execution %s was re-claimed between the ERROR " + "mark and the delete — leaving the fresh claim.", + execution_id, + ) + return None + return _CLAIM_RECOVERED + + +def sweep_orphan_claims( + conn: PgConnection, + api_client: InternalAPIClient, + stuck_timeout_seconds: int | None = None, +) -> int: + """GC / recover orphaned ``pg_orchestration_claim`` rows (UN-3679). Returns the + number of claim rows removed (terminal-tombstone GCs + crash-window recoveries). + + The orchestration claim is taken BEFORE the barrier is armed, so — unlike + ``pg_batch_dedup``, whose barrier always exists — a crash in the claim→arm + window leaves a claim with no barrier row that the barrier sweep can't see, and + a successful claim's tombstone has no natural GC. This sweep closes both: for + each orphan claim (no barrier row, older than the stuck-timeout; see + :func:`_orphan_claim_where`) it GC's a terminal execution's tombstone and marks + a non-terminal (crash-window) execution ERROR. One claim failing (API down, + unreadable status) is logged and skipped — its row is left for the next sweep — + so it never blocks the others. + + ``conn`` runs in manual-commit mode; on any error we roll back before + continuing / re-raising so the connection isn't left in an aborted-txn state. + """ + if stuck_timeout_seconds is None: + stuck_timeout_seconds = barrier_stuck_timeout_seconds() + try: + with conn.cursor() as cur: + cur.execute( + f"SELECT c.execution_id, c.organization_id " + f"FROM {qualified('pg_orchestration_claim')} AS c " + f"WHERE {_orphan_claim_where('c')}", + (stuck_timeout_seconds,), + ) + rows = cur.fetchall() + conn.commit() + except Exception: + _rollback_after_sweep_failure(conn, "pg_orchestration_claim") + raise + + gc_count = 0 + recovered = 0 + failed = 0 # genuine per-row failures (exceptions) — NOT benign skips + for execution_id, organization_id in rows: + try: + outcome = _recover_one_claim( + conn, api_client, execution_id, organization_id, stuck_timeout_seconds + ) + if outcome == _CLAIM_RECOVERED: + recovered += 1 + elif outcome == _CLAIM_GC: + gc_count += 1 + # None = a benign skip (no org / no status / re-armed / unconfirmed + # mark) — logged per-row inside; the row is left for the next sweep. + except Exception: + failed += 1 + with contextlib.suppress(Exception): + conn.rollback() + logger.exception( + "Reaper: failed to recover/GC orphan orchestration claim for " + "execution %s — leaving the row for the next sweep.", + execution_id, + ) + + removed = gc_count + recovered + if removed: + logger.info( + "Reaper: orphan-claim sweep removed %d of %d claim(s) — %d GC'd " + "(terminal), %d recovered (marked ERROR).", + removed, + len(rows), + gc_count, + recovered, + ) + # A non-empty sweep that accomplished NOTHING because every row raised is + # systemic (internal API down / bad migration). Raise so _run_sweep records it + # on the consecutive-failure streak — a clean return would reset that streak + # and hide "the crash-window recovery net is completely down". Mirrors + # recover_expired_barriers' failed-and-nothing-recovered escalation. A PARTIAL + # failure (some rows swept) is only a warning — the net is working. + if failed and not removed: + raise RuntimeError( + f"orphan-claim sweep: all {failed} row(s) failed and nothing was " + f"swept — likely systemic (internal API down / bad migration)" + ) + if failed: + logger.warning( + "Reaper: orphan-claim sweep — %d of %d row(s) failed (left for the next " + "sweep); %d swept.", + failed, + len(rows), + removed, + ) + return removed + + class PgReaper: """Leader-elected recovery loop. Only the lease holder runs recovery work.""" @@ -739,12 +1012,24 @@ def _maybe_sweep(self) -> None: "pg_batch_dedup", lambda conn: sweep_orphan_dedup(conn, self._dedup_retention), ) - if results or dedup: + # Orphan orchestration-claim GC + crash-window recovery (UN-3679). Runs + # here (cadence-gated) rather than every tick: orphan claims are rare and + # already older than the stuck-timeout, and this does a per-row status API + # read, so the 5-min cadence keeps it off the hot path. Independent of the + # other sweeps (its own _run_sweep) so a fault in one can't skip it. + claims = self._run_sweep( + "pg_orchestration_claim", + lambda conn: sweep_orphan_claims( + conn, self._get_api_client(), self._stuck_timeout_seconds + ), + ) + if results or dedup or claims: logger.info( "Reaper: retention sweep deleted %s pg_task_result + " - "%s pg_batch_dedup row(s)", + "%s pg_batch_dedup + %s pg_orchestration_claim row(s)", results, dedup, + claims, ) def _run_sweep(self, table: str, fn: Callable[[PgConnection], int]) -> int: diff --git a/workers/tests/test_orchestration_idempotency.py b/workers/tests/test_orchestration_idempotency.py index 44f1f10470..bbeb61682f 100644 --- a/workers/tests/test_orchestration_idempotency.py +++ b/workers/tests/test_orchestration_idempotency.py @@ -41,17 +41,17 @@ def test_celery_transport_never_claims(self): # The whole guard is PG-only: on Celery it must not even consult the claim # (proves the Celery path is behaviorally untouched). with patch("general.tasks.try_claim_orchestration") as claim: - assert _should_skip_duplicate_orchestration("e1", "celery") is False + assert _should_skip_duplicate_orchestration("e1", "org1", "celery") is False claim.assert_not_called() def test_pg_first_delivery_proceeds(self): with patch("general.tasks.try_claim_orchestration", return_value=True) as claim: - assert _should_skip_duplicate_orchestration("e1", "pg_queue") is False - claim.assert_called_once_with("e1") + assert _should_skip_duplicate_orchestration("e1", "org1", "pg_queue") is False + claim.assert_called_once_with("e1", "org1") # org stamped for the reaper def test_pg_duplicate_delivery_skips(self): with patch("general.tasks.try_claim_orchestration", return_value=False): - assert _should_skip_duplicate_orchestration("e1", "pg_queue") is True + assert _should_skip_duplicate_orchestration("e1", "org1", "pg_queue") is True def test_claim_error_propagates(self): # A DB error in the claim must PROPAGATE, not be swallowed — a swallow-and- @@ -62,7 +62,7 @@ def test_claim_error_propagates(self): side_effect=psycopg2.OperationalError("db down"), ): with pytest.raises(psycopg2.OperationalError): - _should_skip_duplicate_orchestration("e1", "pg_queue") + _should_skip_duplicate_orchestration("e1", "org1", "pg_queue") def test_skip_on_retry_logs_error_with_errorid(self, caplog): # A skip when retries > 0 is a SUPPRESSED retry (a prior attempt claimed @@ -70,7 +70,10 @@ def test_skip_on_retry_logs_error_with_errorid(self, caplog): # with an errorId so alerting can distinguish the two. with patch("general.tasks.try_claim_orchestration", return_value=False): with caplog.at_level(logging.ERROR, logger="general.tasks"): - assert _should_skip_duplicate_orchestration("e1", "pg_queue", 2) is True + assert ( + _should_skip_duplicate_orchestration("e1", "org1", "pg_queue", 2) + is True + ) assert "ORCH_CLAIM_SUPPRESSED_RETRY" in caplog.text @@ -94,7 +97,9 @@ def test_duplicate_returns_skip_without_setup(self): fn = tasks.async_execute_bin_general while hasattr(fn, "__wrapped__"): fn = fn.__wrapped__ - with patch.dict(fn.__globals__, {"try_claim_orchestration": lambda _eid: False}): + with patch.dict( + fn.__globals__, {"try_claim_orchestration": lambda _eid, _org: False} + ): out = tasks.async_execute_bin_general( schema_name="org", workflow_id="wf", @@ -121,7 +126,7 @@ def test_no_release_when_claim_never_acquired(self): # to delete nothing and muddy the log. released: list[str] = [] - def _boom(_eid): + def _boom(_eid, _org): raise psycopg2.OperationalError("db down") with patch.dict( @@ -148,7 +153,7 @@ def test_release_called_when_claimed_and_work_fails(self): with patch.dict( self._task_globals(), { - "try_claim_orchestration": lambda _eid: True, # won the claim + "try_claim_orchestration": lambda _eid, _org: True, # won the claim "release_orchestration_claim": lambda eid: released.append(eid), }, ): @@ -210,32 +215,34 @@ def _claim_count(conn, execution_id): @pytest.mark.integration class TestClaimOrchestration: def test_first_claim_wins(self, claim_db): - assert try_claim_orchestration("exec-1") is True + assert try_claim_orchestration("exec-1", "org-1") is True def test_duplicate_claim_loses(self, claim_db): - assert try_claim_orchestration("exec-1") is True - assert try_claim_orchestration("exec-1") is False # redelivery / sibling + assert try_claim_orchestration("exec-1", "org-1") is True + assert try_claim_orchestration("exec-1", "org-1") is False # redelivery / sibling def test_distinct_executions_are_independent(self, claim_db): - assert try_claim_orchestration("exec-1") is True - assert try_claim_orchestration("exec-2") is True # keyed on execution_id + assert try_claim_orchestration("exec-1", "org-1") is True + assert try_claim_orchestration("exec-2", "org-1") is True # keyed on execution_id def test_claim_persists_as_tombstone(self, claim_db): # NOT cleared at finalise (unlike per-batch markers): a post-completion # redelivery still finds it, so a finished execution can't be re-orchestrated. - assert try_claim_orchestration("exec-keep") is True + assert try_claim_orchestration("exec-keep", "org-1") is True assert _claim_count(claim_db, "exec-keep") == 1 - assert try_claim_orchestration("exec-keep") is False + assert try_claim_orchestration("exec-keep", "org-1") is False def test_release_allows_reclaim(self, claim_db): # The failure-path release frees the slot so a redelivery/retry can # re-orchestrate (this is what stops a transient failure permanently # no-oping every redelivery). - assert try_claim_orchestration("exec-rel") is True - assert try_claim_orchestration("exec-rel") is False # held + assert try_claim_orchestration("exec-rel", "org-1") is True + assert try_claim_orchestration("exec-rel", "org-1") is False # held release_orchestration_claim("exec-rel") assert _claim_count(claim_db, "exec-rel") == 0 - assert try_claim_orchestration("exec-rel") is True # reclaimable after release + assert ( + try_claim_orchestration("exec-rel", "org-1") is True + ) # reclaimable after release def test_release_is_safe_when_absent(self, claim_db): release_orchestration_claim("never-claimed") # no row → no error @@ -265,7 +272,7 @@ def run(conn, eid=execution_id, gate=gate, results=results, lock=lock): pg_barrier._local.conn = conn try: gate.wait() # align all claims at the same instant - won = try_claim_orchestration(eid) + won = try_claim_orchestration(eid, "org-1") with lock: results.append(won) finally: diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index c39ba21cf6..9e6cd455bc 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -496,7 +496,7 @@ def test_execute_phase_reaped_cached_conn_retries_once( monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: healthy) with caplog.at_level("WARNING"): - try_claim_orchestration("exec-1") + try_claim_orchestration("exec-1", "org-1") assert dead.executes == 1 and healthy.executes == 1 # exactly one extra try assert dead.commits == 0 # the reaped attempt never committed @@ -519,7 +519,7 @@ def test_commit_phase_failure_is_not_retried(self, _clean_local, monkeypatch, sl lambda **_k: pytest.fail("must not reconnect on a commit-phase failure"), ) with pytest.raises(psycopg2.OperationalError): - try_claim_orchestration("exec-1") + try_claim_orchestration("exec-1", "org-1") assert conn.executes == 1 and conn.commits == 1 # tried once, no retry assert sleeps == [] # no backoff → no retry @@ -532,10 +532,23 @@ def test_fresh_conn_execute_failure_is_not_retried( pg_barrier._local.conn = None # → reused False monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: dead) with pytest.raises(psycopg2.OperationalError): - try_claim_orchestration("exec-1") + try_claim_orchestration("exec-1", "org-1") assert dead.executes == 1 # no retry assert sleeps == [] + @pytest.mark.parametrize( + "exc_cls", + [psycopg2.errors.UndefinedTable, psycopg2.errors.UndefinedColumn], + ids=["table-missing (0012)", "column-missing (0013)"], + ) + def test_schema_behind_raises_actionable_error(self, _clean_local, exc_cls): + # A schema behind the code — 0012 not run (no table) OR 0012-but-not-0013 + # (table, no organization_id column) — must fail fast with an actionable + # message, NOT the generic per-execution stack trace, and NOT proceed. + pg_barrier._local.conn = _FakeConn(execute_error=exc_cls("schema behind")) + with pytest.raises(RuntimeError, match="schema is out of date"): + try_claim_orchestration("exec-1", "org-1") + # --- Layer 2: enqueue + link/abort with a real injected connection --- diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py index 9f88af5751..e64469b1f3 100644 --- a/workers/tests/test_pg_reaper.py +++ b/workers/tests/test_pg_reaper.py @@ -33,6 +33,7 @@ reaper_sweep_interval_from_env, recover_expired_barriers, sweep_expired_results, + sweep_orphan_claims, sweep_orphan_dedup, ) from queue_backend.pg_queue.schema import qualified @@ -59,9 +60,11 @@ def stub_scheduler_tick(monkeypatch): def stub_retention_sweep(monkeypatch): results = MagicMock(return_value=0) dedup = MagicMock(return_value=0) + claims = MagicMock(return_value=0) monkeypatch.setattr(reaper_mod, "sweep_expired_results", results) monkeypatch.setattr(reaper_mod, "sweep_orphan_dedup", dedup) - return SimpleNamespace(results=results, dedup=dedup) + monkeypatch.setattr(reaper_mod, "sweep_orphan_claims", claims) + return SimpleNamespace(results=results, dedup=dedup, claims=claims) # --- Layer 1: env + construction (no DB) --- @@ -406,6 +409,10 @@ def test_leader_sweeps(self, stub_retention_sweep): reaper.tick() stub_retention_sweep.results.assert_called_once_with(conn) stub_retention_sweep.dedup.assert_called_once_with(conn, 86400) + # Orphan-claim sweep (UN-3679) is wired with the api client + stuck-timeout. + stub_retention_sweep.claims.assert_called_once_with( + conn, reaper._get_api_client(), reaper._stuck_timeout_seconds + ) def test_standby_does_not_sweep(self, stub_retention_sweep): reaper = self._reaper(_FakeLease(acquires=False)) @@ -485,7 +492,10 @@ def test_logs_counts_only_when_rows_deleted(self, stub_retention_sweep, caplog): caplog.at_level(logging.INFO, logger="queue_backend.pg_queue.reaper"), ): reaper.tick() - assert "deleted 0 pg_task_result + 4 pg_batch_dedup row(s)" in caplog.text + assert ( + "deleted 0 pg_task_result + 4 pg_batch_dedup + 0 pg_orchestration_claim " + "row(s)" in caplog.text + ) def test_no_log_when_nothing_deleted(self, stub_retention_sweep, caplog): reaper = self._reaper(_FakeLease(acquires=True, renews=True)) @@ -835,7 +845,12 @@ def test_progress_refresh_mid_recovery_aborts_the_mark(self, barrier_conn): # to now() between the sweep SELECT and the mark → the barrier is no longer # stranded and the reaper must NOT mark its live run ERROR (mirrors the # expires_at re-arm test, but via last_progress_at). - _seed(barrier_conn, "lp-rearm", expired=False, last_progress="now() - interval '1 hour'") + _seed( + barrier_conn, + "lp-rearm", + expired=False, + last_progress="now() - interval '1 hour'", + ) def refresh(execution_id): with barrier_conn.cursor() as cur: @@ -882,9 +897,9 @@ def test_all_skipped_sweep_does_not_log_systemic_error(self, barrier_conn, caplo _seed(barrier_conn, "exp-noorg", expired=True, organization_id="") with caplog.at_level(logging.ERROR, logger="queue_backend.pg_queue.reaper"): recover_expired_barriers(barrier_conn, _FakeApiClient()) - assert not any( - "systemic" in r.message for r in caplog.records - ), "all-skipped sweep should not escalate to a systemic-failure error" + assert not any("systemic" in r.message for r in caplog.records), ( + "all-skipped sweep should not escalate to a systemic-failure error" + ) def test_reclaims_dedup_markers(self, barrier_conn): _seed(barrier_conn, "exp-d", expired=True) @@ -1159,5 +1174,194 @@ def test_main_module_reexports_real_main(self): assert module.main is real_main +# --- Layer 6: orphan orchestration-claim sweep (real Postgres, UN-3679) --- + + +@pytest.fixture +def claim_conn(): + try: + conn = _new_conn() + except psycopg2.OperationalError as exc: + pytest.skip(f"Postgres not reachable: {exc}") + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('pg_orchestration_claim')") + if cur.fetchone()[0] is None: + conn.close() + pytest.skip("pg_orchestration_claim migration not applied (run migrate)") + cur.execute("DELETE FROM pg_orchestration_claim") + cur.execute("DELETE FROM pg_barrier_state") + conn.commit() + yield conn + with conn.cursor() as cur: + cur.execute("DELETE FROM pg_orchestration_claim") + cur.execute("DELETE FROM pg_barrier_state") + conn.commit() + conn.close() + + +# Stuck-timeout used by these tests (seconds): claims OLDER than this are swept. +_CLAIM_STUCK = 60 + + +def _seed_claim(conn, execution_id, *, old, organization_id="org-1"): + # ``old`` seeds claimed_at past/within the stuck-timeout so a test exercises + # the swept vs left-alone branches; committed so it's durable like a real claim. + claimed_sql = "now() - interval '2 minutes'" if old else "now()" + with conn.cursor() as cur: + cur.execute( + "INSERT INTO pg_orchestration_claim " + f"(execution_id, organization_id, claimed_at) VALUES (%s, %s, {claimed_sql})", + (execution_id, organization_id), + ) + conn.commit() + + +def _claim_ids(conn): + with conn.cursor() as cur: + cur.execute("SELECT execution_id FROM pg_orchestration_claim ORDER BY 1") + rows = [r[0] for r in cur.fetchall()] + conn.commit() + return rows + + +@pytest.mark.integration +class TestSweepOrphanClaims: + def test_gc_terminal_tombstone(self, claim_conn): + # A completed execution's tombstone (old, no barrier) → GC'd, no ERROR mark. + _seed_claim(claim_conn, "done-1", old=True) + api = _FakeApiClient(status="COMPLETED") + removed = sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert removed == 1 + assert _claim_ids(claim_conn) == [] # GC'd + assert api.update_calls == [] # terminal → no mark + + def test_recovers_crash_window_marks_error(self, claim_conn): + # Crash in the claim→arm window: old claim, no barrier, execution still + # non-terminal → mark ERROR (+cascade) then delete the claim. + _seed_claim(claim_conn, "strand-1", old=True) + api = _FakeApiClient(status="EXECUTING") + removed = sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert removed == 1 + (call,) = api.update_calls + assert call.execution_id == "strand-1" + assert call.status == "ERROR" + assert call.organization_id == "org-1" + assert call.cascade_terminal_files is True + assert "never armed" in call.error_message + assert _claim_ids(claim_conn) == [] # deleted after the confirmed mark + + def test_leaves_young_claim(self, claim_conn): + # A just-claimed live orchestration (claimed_at within the stuck-timeout) + # must be left alone — not even a status read. + _seed_claim(claim_conn, "live-1", old=False) + api = _FakeApiClient(status="EXECUTING") + removed = sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert removed == 0 + assert api.get_calls == [] # not even inspected + assert _claim_ids(claim_conn) == ["live-1"] + + def test_leaves_claim_with_armed_barrier(self, claim_conn): + # A claim WITH a barrier row is a live/armed run — the barrier sweep owns + # it; the claim sweep must skip it entirely (no status read). + _seed_claim(claim_conn, "armed-1", old=True) + _seed(claim_conn, "armed-1", expired=False) # barrier row present + api = _FakeApiClient(status="EXECUTING") + removed = sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert removed == 0 + assert api.get_calls == [] + assert _claim_ids(claim_conn) == ["armed-1"] + + def test_unconfirmed_mark_leaves_claim(self, claim_conn): + # The status API reports success=False on the mark → do NOT delete the claim + # (it's the only recovery handle); leave it for the next sweep. + _seed_claim(claim_conn, "strand-2", old=True) + api = _FakeApiClient(status="EXECUTING", update_success=False) + removed = sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert removed == 0 + assert len(api.update_calls) == 1 # mark attempted + assert _claim_ids(claim_conn) == ["strand-2"] # but kept + + def test_no_org_leaves_claim_without_api_call(self, claim_conn): + # A claim with no org can't be recovered via the org-scoped API — leave it, + # don't read status. + _seed_claim(claim_conn, "no-org-1", old=True, organization_id="") + api = _FakeApiClient(status="EXECUTING") + removed = sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert removed == 0 + assert api.get_calls == [] + assert _claim_ids(claim_conn) == ["no-org-1"] + + def test_one_failure_does_not_block_others(self, claim_conn): + # A per-claim exception (here: a status read that raises) is caught and the + # row left; the other claim is still swept in the same pass. + _seed_claim(claim_conn, "aaa-done", old=True) + _seed_claim(claim_conn, "zzz-boom", old=True) + + def _raise_for_boom(eid): + if eid == "zzz-boom": + raise RuntimeError("read boom") + + api = _FakeApiClient(status="COMPLETED", on_get=_raise_for_boom) + removed = sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert removed == 1 # aaa-done GC'd despite zzz-boom raising + assert _claim_ids(claim_conn) == ["zzz-boom"] # left for the next sweep + + def test_all_rows_failing_raises_systemic(self, claim_conn): + # A non-empty sweep where EVERY row raises is systemic (API down) → raise so + # _run_sweep records the consecutive-failure streak (a clean return would + # reset it and hide that the recovery net is down). + _seed_claim(claim_conn, "boom-1", old=True) + _seed_claim(claim_conn, "boom-2", old=True) + + def _always_raise(_eid): + raise RuntimeError("api down") + + api = _FakeApiClient(status="COMPLETED", on_get=_always_raise) + with pytest.raises(RuntimeError, match="systemic"): + sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert _claim_ids(claim_conn) == ["boom-1", "boom-2"] # nothing removed + + def test_recheck_race_barrier_armed_leaves_claim(self, claim_conn): + # A slow-but-live orchestration arms its barrier BETWEEN the sweep's SELECT + # and the pre-mark re-check → the claim must NOT be marked ERROR. + _seed_claim(claim_conn, "race-arm", old=True) + + def _arm_barrier(eid): + if eid == "race-arm": + _seed(claim_conn, eid, expired=False) # barrier now exists + + api = _FakeApiClient(status="EXECUTING", on_get=_arm_barrier) + removed = sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert removed == 0 + assert api.update_calls == [] # re-check caught it → no ERROR mark + assert _claim_ids(claim_conn) == ["race-arm"] # left for the live run + + def test_delete_reguard_reclaim_leaves_fresh_claim(self, claim_conn): + # Terminal → GC path, but the claim is released + re-claimed with a fresh + # claimed_at between the SELECT and the DELETE → the re-guarded DELETE + # matches 0 rows → the fresh claim survives and is NOT counted. + _seed_claim(claim_conn, "race-gc", old=True) + + def _reclaim(eid): + if eid == "race-gc": + with claim_conn.cursor() as cur: + cur.execute( + "DELETE FROM pg_orchestration_claim WHERE execution_id = %s", + (eid,), + ) + cur.execute( + "INSERT INTO pg_orchestration_claim " + "(execution_id, organization_id, claimed_at) " + "VALUES (%s, 'org-1', now())", + (eid,), + ) + claim_conn.commit() + + api = _FakeApiClient(status="COMPLETED", on_get=_reclaim) + removed = sweep_orphan_claims(claim_conn, api, _CLAIM_STUCK) + assert removed == 0 # 0-row delete → not counted + assert _claim_ids(claim_conn) == ["race-gc"] # the fresh claim survives + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From b84980b05f58986fe8b664938ee669e8eedd2220 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:57:56 +0530 Subject: [PATCH 58/65] =?UTF-8?q?UN-3672=20[FEAT]=20PG=20queue=20=E2=80=94?= =?UTF-8?q?=20application-level=20/metrics=20exporter=20(queue=20depth,=20?= =?UTF-8?q?message=20age,=20barriers,=20reaper=20outcomes)=20(#2143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3672 [FEAT] PG queue — application-level /metrics exporter (queue depth, oldest-message age, barriers, reaper outcomes) The PG path emitted zero metrics — detection of an outage was "a user complains". Today's queue observability comes from RabbitMQ's built-in Prometheus plugin; Postgres has no such plugin (the pg_queue tables ARE the broker), so the exporter must be ours. Application-level ONLY by design — no chart/infra changes; the PodMonitoring scrape config + alert rules are deferred to a follow-up (infra alignment). Until then /metrics is curl-able (incidents, the load-test harness) but nothing collects it in cloud. - metrics.py (new): ConsumerMetrics (per-pod poll-loop heartbeat gauge; the supervisor adds alive-children/concurrency) + ReaperMetrics (recovery outcome counters, leadership gauge, cached queue-wide gauges). Registries are instance-owned, never the prometheus_client default REGISTRY — the workers tree imports under two module names in places (bare-tasks sys.path quirk) and module-level collectors would double-register. - liveness.py: optional /metrics route on the EXISTING health server — same port, opt-in via metrics_fn, prometheus text content-type. A broken renderer 500s /metrics only; the /health verdict is untouched. - reaper.py: exports pg_queue_depth{queue}, pg_queue_oldest_message_age_ seconds{queue}, pg_barrier_live/stranded (same stranded predicate as recovery), pg_reaper_*_total outcome counters, pg_reaper_is_leader. Queue gauges are CACHED snapshots refreshed by the leader every 60s in the tick — a scrape never touches the DB. Snapshot cleared on losing leadership (a standby must not export a frozen stale snapshot); refresh failure is counted + logged, never fails the tick; drained queues drop out of the series rather than freezing at the last non-zero value. - consumer.py/supervisor.py: wire metrics_fn into their liveness servers. - OPERATIONS.md: replace the false "metrics endpoint" claim with the real endpoints and the honest not-scraped-in-cloud-yet note. prometheus-client was already a declared (unused) dependency — no new deps. Celery safety: all changes are inside PG-only modules Celery workers never import; proven by the untouched full unit lane. Tests: 23 new (route isolation, snapshot set/clear/drain semantics, counter threading, tick cadence-gating + failure tolerance, end-to-end HTTP on both server subclasses). 1192 unit + 123 integration pass; live smoke against a real dev Postgres rendered real values (and surfaced a 15-day-old stranded dev notifications backlog — the exact failure class this exists to catch). Co-Authored-By: Claude Fable 5 * UN-3672 [FIX] Address PR review — atomic snapshot collector, cadence reset on step-down, tick-failure counter, Sonar float-eq PR Review Toolkit (6 agents, 18 findings) + SonarCloud quality gate on #2143. Critical (both real): - C1 leadership flap: clear_queue_snapshot() wiped the gauges but the reaper's refresh-cadence gate was not reset, so a re-elected leader within the 60s interval exported a false "empty queue" (is_leader=1, all zeros). Step-down now goes through _step_down_metrics() which clears AND resets the cadence — the re-elected leader refreshes immediately (tested: refresh fires in the same tick as re-acquisition). - C2 TOCTOU: _seconds_since_refresh read the mutable timestamp twice (None check then arithmetic); a concurrent clear between them raised TypeError inside generate_latest, 500ing the whole scrape during leadership churn. Both criticals + the torn-scrape finding (I2: prometheus locks per metric, a scrape between clear() and repopulation saw empty/mixed state) shared one root cause — per-metric gauges mutated non-atomically. Redesigned: the queue gauges are now rendered by a custom collector from ONE immutable _QueueSnapshot swapped by reference. A scrape reads a single snapshot object — it can never tear, race a clear, or read a mutable field twice. set/clear became pure snapshot replacement. Important: - I1 staleness metric reported maximal freshness (0) in the maximal-staleness case (leader whose refresh fails since boot; no-leader fleet). The snapshot now carries reference_monotonic (construction / refresh / step-down time) so pg_queue_gauges_age_seconds GROWS while refreshes fail — an age alert can fire; help text documents alerting on it together with is_leader==1. - I3 whole-tick failures (recovery SELECT / scheduler raising into run()'s catch) had no metric while the heartbeat stays fresh (stamped at tick START) — added pg_reaper_tick_failures_total, incremented in run()'s except. - I4 pg_barrier_live help/SQL mismatch — SQL now FILTERs remaining>0 (matching the help); stranded stays predicate-only and its help now says it includes remaining==0 lingerers. - I5 OPERATIONS.md — reaper heartbeat metric documented, consumer bullet rescoped, failure counters enumerated symmetrically, 60s cadence bound named. Test gaps (pr-test-analyzer): cadence RESUMPTION after the interval elapses + failure-consumes-the-interval (T1); SQL contract pinned — tables, GROUP BY, stranded predicate, bound stuck-timeout param (T2); supervisor fleet-metrics end-to-end through _maybe_start_supervisor_health (T3, the only producer of the fleet gauges); sweep_failures{table} increment + not-reset-on-success (T4); renew-RAISES step-down clears the snapshot (T5); /metrics?query stripping; leader-tick refresh args pinned to the reaper's own exporter. Suggestions: header writes guarded against client hangup on both routes (S1); clear/standby wording fixed — series are dropped, not zeroed (S2, moot under the redesign); interval comment references the knob not "~5s" (S4); shared _Exporter base collapses the registry/render/function-gauge triplication (S5); test boilerplate collapsed via a _running() context manager (S6). SonarCloud (C reliability, 24 bugs + 1 smell): all float equality assertions now use pytest.approx; the implicit string concat is gone with the redesign. 31 tests (was 23); 1200 unit + 123 integration pass. Co-Authored-By: Claude Fable 5 * UN-3672 [FIX] Address Greptile + Sonar — collector describe() + merge implicit concat - Greptile: _QueueSnapshotCollector now implements describe(), so CollectorRegistry.register() checks names against stable descriptors instead of invoking the full collect() render path (clock read, five family allocations) as a registration side effect. One _families() builder feeds both describe() (names only) and collect() (names + samples) so they can't drift; test pins same-names + no-samples. - Sonar S5799: merged the implicitly concatenated help string on the claim-GC counter ("execution already " "terminal" — a formatter artifact). 32 tests pass. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- workers/OPERATIONS.md | 38 +- workers/pg_queue_consumer/supervisor.py | 8 + workers/queue_backend/pg_queue/consumer.py | 7 +- workers/queue_backend/pg_queue/liveness.py | 51 +- workers/queue_backend/pg_queue/metrics.py | 305 +++++++++++ workers/queue_backend/pg_queue/reaper.py | 131 ++++- workers/tests/test_pg_metrics.py | 579 +++++++++++++++++++++ workers/tests/test_pg_reaper.py | 12 +- 8 files changed, 1116 insertions(+), 15 deletions(-) create mode 100644 workers/queue_backend/pg_queue/metrics.py create mode 100644 workers/tests/test_pg_metrics.py diff --git a/workers/OPERATIONS.md b/workers/OPERATIONS.md index 23faa5ca22..d7c85efbf1 100644 --- a/workers/OPERATIONS.md +++ b/workers/OPERATIONS.md @@ -70,11 +70,39 @@ curl http://localhost:8083/health # Callback worker ### Metrics (Prometheus) -Workers expose metrics on their health ports at `/metrics`: -- Task execution counts -- Processing times -- Queue depths -- Error rates +**PG-queue processes** serve Prometheus text-format metrics at `/metrics` on +their existing health port (`WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT` for +consumers, `WORKER_PG_REAPER_HEALTH_PORT` for the reaper — same server as +`/health`; no port configured means neither endpoint): + +- Every PG consumer / the fleet supervisor (per-pod): + `pg_consumer_heartbeat_age_seconds` (poll-loop freshness — the same signal + `/health` verdicts on); the supervisor adds `pg_consumer_alive_children` / + `pg_consumer_configured_concurrency`. +- Reaper only (the leader-elected singleton — queue-WIDE state comes from one + process): `pg_reaper_heartbeat_age_seconds` (tick-loop freshness), + `pg_queue_depth{queue}`, `pg_queue_oldest_message_age_seconds{queue}`, + `pg_barrier_live` / `pg_barrier_stranded`, outcome + failure counters + (`pg_reaper_barrier_*_total`, `pg_reaper_claim_*_total`, + `pg_reaper_sweep_failures_total{table}`, `pg_reaper_tick_failures_total`, + `pg_reaper_gauge_refresh_failures_total`) and `pg_reaper_is_leader`. + Queue gauges are cached snapshots refreshed on the reaper's own cadence + (60s — `_GAUGE_REFRESH_INTERVAL_SECONDS` in `reaper.py`) — scrapes never + touch the DB; `pg_queue_gauges_age_seconds` exposes snapshot staleness and + keeps growing while refreshes fail or the pod is a standby (alert on it + together with `pg_reaper_is_leader == 1`). + +```bash +curl http://localhost:8090/metrics # PG consumer / supervisor +curl http://localhost:8086/metrics # PG reaper +``` + +Nothing scrapes these in cloud yet (no PodMonitoring/alerts — deferred); +they're for direct curl during incidents, dashboards-to-come, and the +load-test harness. + +**Celery workers** expose no app-level `/metrics`; their queue observability +comes from RabbitMQ's Prometheus plugin and Flower (below). ### Logging diff --git a/workers/pg_queue_consumer/supervisor.py b/workers/pg_queue_consumer/supervisor.py index cc99ef091b..bc2a5564ed 100644 --- a/workers/pg_queue_consumer/supervisor.py +++ b/workers/pg_queue_consumer/supervisor.py @@ -450,6 +450,13 @@ def _extra_status() -> dict[str, object]: "liveness_probe_bound": True, } + from queue_backend.pg_queue.metrics import ConsumerMetrics + + metrics = ConsumerMetrics( + freshness_fn=fleet.freshness, + alive_children_fn=lambda: float(fleet.alive_count()), + concurrency_fn=lambda: float(fleet.concurrency), + ) server = LivenessServer( freshness_fn=fleet.freshness, stale_after=stale_after, @@ -457,6 +464,7 @@ def _extra_status() -> dict[str, object]: check_name="pg_queue_fleet", age_key="oldest_child_seconds_since_poll", extra_status_fn=_extra_status, + metrics_fn=metrics.render, thread_name="pg-supervisor-liveness", log_label="pg-queue supervisor", ) diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index a00c556079..c356ecf7a9 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -867,18 +867,23 @@ class LivenessServer(_BaseLivenessServer): """Consumer poll-loop liveness — a thin wrapper over the shared :class:`queue_backend.pg_queue.liveness.LivenessServer`, bound to the consumer's heartbeat (``seconds_since_last_poll``). Same wire shape as before - (``/health`` → 200 fresh / 503 stale, ``check="pg_queue_poll"``). + (``/health`` → 200 fresh / 503 stale, ``check="pg_queue_poll"``), plus + ``/metrics`` exporting that heartbeat as a scrapeable gauge. """ def __init__( self, consumer: PgQueueConsumer, *, port: int, stale_after: float ) -> None: + from .metrics import ConsumerMetrics + + metrics = ConsumerMetrics(freshness_fn=consumer.seconds_since_last_poll) super().__init__( freshness_fn=consumer.seconds_since_last_poll, stale_after=stale_after, port=port, check_name="pg_queue_poll", age_key="seconds_since_last_poll", + metrics_fn=metrics.render, thread_name="pg-consumer-liveness", log_label="pg-queue consumer", ) diff --git a/workers/queue_backend/pg_queue/liveness.py b/workers/queue_backend/pg_queue/liveness.py index 7256a0e2a4..07f41f6ceb 100644 --- a/workers/queue_backend/pg_queue/liveness.py +++ b/workers/queue_backend/pg_queue/liveness.py @@ -21,6 +21,7 @@ from __future__ import annotations +import contextlib import logging from collections.abc import Callable from typing import TYPE_CHECKING, Any @@ -39,9 +40,19 @@ class LivenessServer: ``check_name`` / ``age_key`` label the JSON payload; ``extra_status_fn`` (optional) merges extra fields in (informational — it never affects the 200/503 verdict, which is purely the freshness heartbeat). + + ``metrics_fn`` (optional) additionally serves ``/metrics``: it returns the + Prometheus text-exposition body (bytes). Kept as an opaque callable so this + module stays free of the prometheus dependency; the metric definitions live + in :mod:`queue_backend.pg_queue.metrics`. A ``metrics_fn`` failure returns + 500 on ``/metrics`` only — it can never affect the ``/health`` verdict. """ _PATHS = frozenset({"/health", "/healthz", "/livez"}) + _METRICS_PATH = "/metrics" + # Prometheus text exposition format (metrics.METRICS_CONTENT_TYPE — inlined + # so this module keeps zero imports from the metrics side). + _METRICS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8" def __init__( self, @@ -52,6 +63,7 @@ def __init__( check_name: str, age_key: str, extra_status_fn: Callable[[], dict[str, Any]] | None = None, + metrics_fn: Callable[[], bytes] | None = None, thread_name: str = "pg-queue-liveness", log_label: str = "pg-queue", ) -> None: @@ -66,6 +78,7 @@ def __init__( self._check_name = check_name self._age_key = age_key self._extra_status_fn = extra_status_fn + self._metrics_fn = metrics_fn self._thread_name = thread_name # Prefixes the (now-shared) log messages so they stay attributable to the # source process after the consumer/reaper extraction (e.g. "pg-queue @@ -86,6 +99,9 @@ def start(self) -> None: freshness_fn = self._freshness_fn stale_after = self._stale_after paths = self._PATHS + metrics_path = self._METRICS_PATH + metrics_content_type = self._METRICS_CONTENT_TYPE + metrics_fn = self._metrics_fn check_name = self._check_name age_key = self._age_key extra_status_fn = self._extra_status_fn @@ -94,7 +110,11 @@ def start(self) -> None: class _Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: # Strip any query string — a probe like /health?foo=bar must match. - if urlsplit(self.path).path not in paths: + path = urlsplit(self.path).path + if metrics_fn is not None and path == metrics_path: + self._serve_metrics() + return + if path not in paths: self.send_response(404) self.end_headers() return @@ -118,14 +138,37 @@ def do_GET(self) -> None: } ) body = json.dumps(payload).encode() - self.send_response(503 if stale else 200) - self.send_header("Content-Type", "application/json") - self.end_headers() + # Headers too, not just the body write: a client that hangs up + # before headers go out would otherwise raise into socketserver's + # handle_error, which prints an unattributed traceback to stderr. try: + self.send_response(503 if stale else 200) + self.send_header("Content-Type", "application/json") + self.end_headers() self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass # client (probe) hung up mid-response — not our problem + def _serve_metrics(self) -> None: + # A broken metrics renderer must degrade to a 500 on /metrics + # alone — the probe verdict on /health stays untouched. + try: + body = metrics_fn() # type: ignore[misc] # guarded by caller + except Exception: + logger.exception("%s: /metrics render failed", log_label) + with contextlib.suppress(BrokenPipeError, ConnectionResetError): + self.send_response(500) + self.end_headers() + return + # Same hung-up-client guard as the /health path above. + try: + self.send_response(200) + self.send_header("Content-Type", metrics_content_type) + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass # scraper hung up mid-response — not our problem + def log_message(self, *_: object) -> None: pass # silence per-request access logging diff --git a/workers/queue_backend/pg_queue/metrics.py b/workers/queue_backend/pg_queue/metrics.py new file mode 100644 index 0000000000..2e1230f290 --- /dev/null +++ b/workers/queue_backend/pg_queue/metrics.py @@ -0,0 +1,305 @@ +"""Prometheus metrics for PG-queue processes (application-level exporter). + +Today's queue observability comes from RabbitMQ's built-in Prometheus plugin; +Postgres has no such plugin — the pg_queue tables ARE the broker — so the +exporter is ours. This module holds the metric *definitions*; the SQL that +feeds the queue-wide gauges lives with its sibling queries in ``reaper.py`` +(it shares the stranded-barrier predicate), and the HTTP surface is the +existing :class:`~queue_backend.pg_queue.liveness.LivenessServer` ``/metrics`` +route — no new port, no new server, no execution-path changes. + +Two exporters, matching the two process shapes: + +- :class:`ConsumerMetrics` — per-pod, on every PG consumer: poll-loop heartbeat + freshness (the same signal ``/health`` verdicts on, as a scrapeable number). +- :class:`ReaperMetrics` — queue-WIDE state, exported only by the reaper: it is + the leader-elected singleton, so queue depth / oldest-message age / barrier + counts come from one process instead of N pods running identical SQL and + emitting duplicate series. On a standby the per-queue series are absent and + the barrier gauges read 0 — disambiguate with ``pg_reaper_is_leader``. + +The queue-wide gauges are backed by ONE immutable snapshot object swapped by +reference (:class:`_QueueSnapshot`): a scrape reads a single consistent +snapshot, so it can never observe a torn state (new depths with old barrier +counts) or race the tick thread's clear — the swap is atomic and the render +side never reads mutable fields twice. + +Registries are instance-owned (never the ``prometheus_client`` default +``REGISTRY``): the workers tree is imported under more than one module name in +places (the bare-``tasks`` sys.path quirk), and module-level collectors would +double-register on the second import. An instance per process cannot. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from prometheus_client import CollectorRegistry + from prometheus_client.core import Metric + +logger = logging.getLogger(__name__) + +# Prometheus text exposition format (the /metrics Content-Type). +METRICS_CONTENT_TYPE: Final = "text/plain; version=0.0.4; charset=utf-8" + + +def _new_registry() -> CollectorRegistry: + from prometheus_client import CollectorRegistry + + return CollectorRegistry() + + +class _Exporter: + """Shared exporter shell: an instance-owned registry + text render.""" + + def __init__(self) -> None: + self.registry = _new_registry() + + def _function_gauge(self, name: str, doc: str, fn: Callable[[], float]) -> None: + from prometheus_client import Gauge + + Gauge(name, doc, registry=self.registry).set_function(fn) + + def render(self) -> bytes: + from prometheus_client import generate_latest + + return generate_latest(self.registry) + + +class ConsumerMetrics(_Exporter): + """Per-pod metrics for a PG-queue consumer (or the fleet supervisor). + + ``freshness_fn`` is the same heartbeat the liveness probe reads — + seconds since the poll loop last made progress (for the supervisor, the + OLDEST child's, so one wedged child surfaces). The optional fleet hooks + exist because the supervisor's ``/health`` JSON already reports them and + an operator graphing the fleet needs them as numbers, not JSON. + """ + + def __init__( + self, + *, + freshness_fn: Callable[[], float], + alive_children_fn: Callable[[], float] | None = None, + concurrency_fn: Callable[[], float] | None = None, + ) -> None: + super().__init__() + self._function_gauge( + "pg_consumer_heartbeat_age_seconds", + "Seconds since the consumer poll loop last made progress " + "(the liveness heartbeat; /health goes 503 past its stale window)", + freshness_fn, + ) + if alive_children_fn is not None: + self._function_gauge( + "pg_consumer_alive_children", + "Live child consumer processes in the supervisor fleet", + alive_children_fn, + ) + if concurrency_fn is not None: + self._function_gauge( + "pg_consumer_configured_concurrency", + "Configured child-process concurrency of the supervisor fleet", + concurrency_fn, + ) + + +@dataclass(frozen=True) +class _QueueSnapshot: + """One immutable queue-wide observation, swapped into the collector by + reference — the atomicity unit for scrapes. + + ``reference_monotonic`` anchors ``pg_queue_gauges_age_seconds``: the refresh + time for a real snapshot, or the construction/step-down time for an empty + one — so a leader whose refresh has been failing since boot shows an + ever-GROWING age (a staleness alert can fire) instead of a frozen 0. + """ + + depths: Mapping[str, tuple[int, float]] = field(default_factory=dict) + barriers_live: int = 0 + barriers_stranded: int = 0 + reference_monotonic: float = field(default_factory=time.monotonic) + + +class _QueueSnapshotCollector: + """Custom collector rendering the current :class:`_QueueSnapshot`. + + ``collect`` reads ``self._snapshot`` exactly once, so a concurrent + ``replace`` (tick thread) can never tear a scrape (HTTP thread) — the + scrape sees the whole old snapshot or the whole new one. + """ + + def __init__(self) -> None: + self._snapshot = _QueueSnapshot() + + def replace(self, snapshot: _QueueSnapshot) -> None: + self._snapshot = snapshot # atomic reference swap + + @staticmethod + def _families() -> tuple[Metric, ...]: + """The five empty metric families — one builder so ``describe`` (names + only) and ``collect`` (names + samples) can never drift. + """ + from prometheus_client.core import GaugeMetricFamily + + return ( + GaugeMetricFamily( + "pg_queue_depth", + "Messages currently in pg_queue_message, by queue (cached " + "snapshot; a drained queue's series is absent, not 0)", + labels=["queue"], + ), + GaugeMetricFamily( + "pg_queue_oldest_message_age_seconds", + "Age of the oldest message in the queue (cached snapshot)", + labels=["queue"], + ), + GaugeMetricFamily( + "pg_barrier_live", + "pg_barrier_state rows with remaining > 0 (in-flight fan-outs)", + ), + GaugeMetricFamily( + "pg_barrier_stranded", + "Barrier rows past the stuck-timeout / expiry — what the next " + "recovery pass picks up (includes remaining==0 lingerers)", + ), + GaugeMetricFamily( + "pg_queue_gauges_age_seconds", + "Seconds since the queue gauges were last refreshed (since " + "process start / leadership step-down if never) — alert on " + "this AND pg_reaper_is_leader==1; a standby's age grows by " + "design", + ), + ) + + def describe(self) -> Iterable[Metric]: + # Registration protocol: with describe() present, register() checks + # names against these descriptors instead of invoking the full + # collect() render path (and its clock read) as a side effect. + return self._families() + + def collect(self) -> Iterable[Metric]: + snapshot = self._snapshot # single read — the consistency point + depth, oldest, live, stranded, age = self._families() + for queue, (msg_count, oldest_age) in snapshot.depths.items(): + depth.add_metric([queue], msg_count) + oldest.add_metric([queue], oldest_age) + live.add_metric([], snapshot.barriers_live) + stranded.add_metric([], snapshot.barriers_stranded) + age.add_metric([], time.monotonic() - snapshot.reference_monotonic) + return (depth, oldest, live, stranded, age) + + +class ReaperMetrics(_Exporter): + """Queue-wide + reaper-outcome metrics, exported by the reaper process. + + The queue gauges are CACHED snapshots — the reaper refreshes them on its + own cadence (leader only) and a scrape never touches the DB, so a hot + scraper (or a curl loop during an incident) cannot add DB load. + ``pg_queue_gauges_age_seconds`` exposes snapshot staleness and keeps + growing while refreshes fail or the process is a standby. + + Outcome counters are incremented by the recovery/sweep code at the same + sites that log the outcomes (see ``reaper.py``). + """ + + def __init__( + self, + *, + heartbeat_fn: Callable[[], float], + is_leader_fn: Callable[[], bool], + ) -> None: + from prometheus_client import Counter + + super().__init__() + self._function_gauge( + "pg_reaper_heartbeat_age_seconds", + "Seconds since the reaper tick loop last ran (liveness heartbeat)", + heartbeat_fn, + ) + self._function_gauge( + "pg_reaper_is_leader", + "1 while this reaper holds the leader lease, else 0", + lambda: 1.0 if is_leader_fn() else 0.0, + ) + + self.barrier_recovered = Counter( + "pg_reaper_barrier_recovered_total", + "Stranded barriers recovered (execution marked ERROR + rows removed)", + registry=self.registry, + ) + self.barrier_recovery_failures = Counter( + "pg_reaper_barrier_recovery_failures_total", + "Stranded-barrier recovery attempts that raised (row left for retry)", + registry=self.registry, + ) + self.claim_recovered = Counter( + "pg_reaper_claim_recovered_total", + "Orphan orchestration claims recovered (crash-window execution " + "marked ERROR)", + registry=self.registry, + ) + self.claim_gc = Counter( + "pg_reaper_claim_gc_total", + "Orphan orchestration-claim tombstones GC'd (execution terminal)", + registry=self.registry, + ) + self.claim_recovery_failures = Counter( + "pg_reaper_claim_recovery_failures_total", + "Orphan-claim recovery attempts that raised (row left for retry)", + registry=self.registry, + ) + self.sweep_failures = Counter( + "pg_reaper_sweep_failures_total", + "Whole-sweep failures, by swept table (see the reaper fail-streak log)", + ["table"], + registry=self.registry, + ) + self.tick_failures = Counter( + "pg_reaper_tick_failures_total", + "Reaper cycles that raised (recovery/scheduler SELECT failures — the " + "heartbeat stays fresh through these, so alert on this counter)", + registry=self.registry, + ) + self.gauge_refresh_failures = Counter( + "pg_reaper_gauge_refresh_failures_total", + "Failed queue-gauge snapshot refreshes (metrics stale, queue unaffected)", + registry=self.registry, + ) + + self._queue_collector = _QueueSnapshotCollector() + self.registry.register(self._queue_collector) + + def set_queue_snapshot( + self, + *, + depths: dict[str, tuple[int, float]], + barriers_live: int, + barriers_stranded: int, + ) -> None: + """Publish a fresh queue-wide snapshot (atomic swap; see collector). + + ``depths`` maps queue name -> (message count, oldest-message age in + seconds). A queue that drained to zero rows simply drops out of the + series rather than freezing at its last non-zero value. + """ + self._queue_collector.replace( + _QueueSnapshot( + depths=dict(depths), + barriers_live=barriers_live, + barriers_stranded=barriers_stranded, + ) + ) + + def clear_queue_snapshot(self) -> None: + """Drop the per-queue series and zero the barrier gauges (called on + losing leadership — a standby must not export a frozen stale snapshot + as if it were live; its ``pg_queue_gauges_age_seconds`` restarts from + the step-down and keeps growing). + """ + self._queue_collector.replace(_QueueSnapshot()) diff --git a/workers/queue_backend/pg_queue/reaper.py b/workers/queue_backend/pg_queue/reaper.py index 524520ac94..9e848b7c3a 100644 --- a/workers/queue_backend/pg_queue/reaper.py +++ b/workers/queue_backend/pg_queue/reaper.py @@ -69,6 +69,7 @@ from .connection import create_pg_connection from .leader_election import LeaderLease, default_worker_id from .liveness import LivenessServer as _BaseLivenessServer +from .metrics import ReaperMetrics from .pg_scheduler import dispatch_due_schedules from .recovery import mark_execution_error from .schema import qualified @@ -474,6 +475,7 @@ def recover_expired_barriers( conn: PgConnection, api_client: InternalAPIClient, stuck_timeout_seconds: int | None = None, + metrics: ReaperMetrics | None = None, ) -> list[str]: """Recover stranded executions. Returns recovered ids. @@ -540,6 +542,9 @@ def recover_expired_barriers( execution_id, ) + if metrics is not None: + metrics.barrier_recovered.inc(len(recovered)) + metrics.barrier_recovery_failures.inc(failed) if rows: skipped = len(rows) - len(recovered) - failed summary = ( @@ -727,6 +732,7 @@ def sweep_orphan_claims( conn: PgConnection, api_client: InternalAPIClient, stuck_timeout_seconds: int | None = None, + metrics: ReaperMetrics | None = None, ) -> int: """GC / recover orphaned ``pg_orchestration_claim`` rows (UN-3679). Returns the number of claim rows removed (terminal-tombstone GCs + crash-window recoveries). @@ -785,6 +791,10 @@ def sweep_orphan_claims( ) removed = gc_count + recovered + if metrics is not None: + metrics.claim_recovered.inc(recovered) + metrics.claim_gc.inc(gc_count) + metrics.claim_recovery_failures.inc(failed) if removed: logger.info( "Reaper: orphan-claim sweep removed %d of %d claim(s) — %d GC'd " @@ -816,6 +826,59 @@ def sweep_orphan_claims( return removed +# Queue-gauge snapshot cadence. Deliberately a module constant, not an env knob: +# it only bounds metrics staleness (the tick already runs every +# _DEFAULT_REAPER_INTERVAL_SECONDS by default), and the +# snapshot is two cheap aggregate reads. +_GAUGE_REFRESH_INTERVAL_SECONDS: Final = 60.0 + + +def refresh_queue_gauges( + conn: PgConnection, metrics: ReaperMetrics, stuck_timeout_seconds: int +) -> None: + """Take one queue-wide snapshot into ``metrics`` (leader-only caller). + + Two aggregate reads: per-queue depth + oldest-message age over + ``pg_queue_message`` (all rows — ready and in-flight — since a backlog is a + backlog either way), and live/stranded counts over ``pg_barrier_state`` + (live = ``remaining > 0`` in-flight fan-outs; stranded = what the next + recovery pass would pick up — same predicate, unfiltered by ``remaining``, + so it includes ``remaining==0`` delete-failure lingerers). + + ``conn`` runs in manual-commit mode; on any error we roll back before + re-raising so the connection isn't left in an aborted-txn state (the caller + counts the failure and discards an owned connection). + """ + try: + with conn.cursor() as cur: + cur.execute( + "SELECT queue_name, count(*), " + "COALESCE(EXTRACT(EPOCH FROM now() - min(enqueued_at)), 0) " + f"FROM {qualified('pg_queue_message')} GROUP BY queue_name" + ) + depth_rows = cur.fetchall() + cur.execute( + "SELECT count(*) FILTER (WHERE remaining > 0), " + f"count(*) FILTER (WHERE {_STRANDED_PREDICATE}) " + f"FROM {qualified('pg_barrier_state')}", + (stuck_timeout_seconds,), + ) + barriers_live, barriers_stranded = cur.fetchone() + conn.commit() + except Exception: + with contextlib.suppress(Exception): + conn.rollback() + raise + metrics.set_queue_snapshot( + depths={ + queue: (int(depth), float(oldest_age)) + for queue, depth, oldest_age in depth_rows + }, + barriers_live=int(barriers_live), + barriers_stranded=int(barriers_stranded), + ) + + class PgReaper: """Leader-elected recovery loop. Only the lease holder runs recovery work.""" @@ -894,6 +957,18 @@ def __init__( # standby tick counts as progress too (the loop is alive), so this tracks # loop liveness, not leadership. self._last_tick_monotonic = time.monotonic() + # Queue-wide metrics snapshot cadence (same None-sentinel pattern as the + # sweep gate: first leader tick refreshes immediately). + self._last_gauge_refresh_monotonic: float | None = None + self._metrics = ReaperMetrics( + heartbeat_fn=self.seconds_since_last_tick, + is_leader_fn=lambda: self._is_leader, + ) + + @property + def metrics(self) -> ReaperMetrics: + """This process's metrics exporter (served at ``/metrics``).""" + return self._metrics @property def is_leader(self) -> bool: @@ -951,6 +1026,7 @@ def tick(self) -> TickOutcome: # A raised renew == "leadership unknown": stop acting (honour the # lease's documented contract) before letting it propagate. self._is_leader = False + self._step_down_metrics() raise if not still_leader: logger.warning( @@ -958,6 +1034,7 @@ def tick(self) -> TickOutcome: "to standby" ) self._is_leader = False + self._step_down_metrics() if not self._is_leader and self._lease.try_acquire(): self._is_leader = True logger.info("Reaper: acquired leadership") @@ -969,6 +1046,7 @@ def tick(self) -> TickOutcome: self._get_sweep_conn(), self._get_api_client(), self._stuck_timeout_seconds, + metrics=self._metrics, ) ) except Exception: @@ -987,6 +1065,9 @@ def tick(self) -> TickOutcome: # Orchestrator's third job: retention cleanup (cadence-gated, so it does # NOT run every tick). Last so a sweep error can't skip recovery/schedules. self._maybe_sweep() + # Queue-wide metrics snapshot (cadence-gated, best-effort — a metrics + # failure must never fail the tick). After all real work. + self._maybe_refresh_gauges() return TickOutcome(was_leader=True, reclaimed=reclaimed) def _maybe_sweep(self) -> None: @@ -1020,7 +1101,10 @@ def _maybe_sweep(self) -> None: claims = self._run_sweep( "pg_orchestration_claim", lambda conn: sweep_orphan_claims( - conn, self._get_api_client(), self._stuck_timeout_seconds + conn, + self._get_api_client(), + self._stuck_timeout_seconds, + metrics=self._metrics, ), ) if results or dedup or claims: @@ -1047,6 +1131,7 @@ def _run_sweep(self, table: str, fn: Callable[[PgConnection], int]) -> int: except Exception: streak = self._sweep_fail_streak.get(table, 0) + 1 self._sweep_fail_streak[table] = streak + self._metrics.sweep_failures.labels(table=table).inc() logger.exception( "Reaper: retention sweep of %s failed (%s consecutive) — will retry " "after the next sweep interval", @@ -1058,6 +1143,45 @@ def _run_sweep(self, table: str, fn: Callable[[PgConnection], int]) -> int: self._sweep_fail_streak[table] = 0 return count + def _step_down_metrics(self) -> None: + """On losing leadership: drop the queue snapshot AND reset the refresh + cadence. Resetting the cadence is load-bearing — without it, a lease + flap that re-acquires within the refresh interval would gate the next + refresh out, leaving a re-elected leader exporting the just-cleared + (false "empty queue") snapshot for up to a full interval. + """ + self._metrics.clear_queue_snapshot() + self._last_gauge_refresh_monotonic = None + + def _maybe_refresh_gauges(self) -> None: + """Refresh the queue-wide metrics snapshot at most once per + :data:`_GAUGE_REFRESH_INTERVAL_SECONDS` (leader-only, called from + :meth:`tick`). Best-effort: metrics must never fail the tick, so a + failure is counted + logged and the snapshot simply goes stale + (``pg_queue_gauges_age_seconds`` exposes exactly that). The cadence is + advanced BEFORE the read so a persistent failure retries once per + interval rather than every tick — same pattern as :meth:`_maybe_sweep`. + """ + now = time.monotonic() + if ( + self._last_gauge_refresh_monotonic is not None + and now - self._last_gauge_refresh_monotonic < _GAUGE_REFRESH_INTERVAL_SECONDS + ): + return + self._last_gauge_refresh_monotonic = now + try: + refresh_queue_gauges( + self._get_sweep_conn(), self._metrics, self._stuck_timeout_seconds + ) + except Exception: + self._metrics.gauge_refresh_failures.inc() + logger.warning( + "Reaper: queue-gauge snapshot refresh failed — metrics go stale " + "until the next interval; the queue itself is unaffected", + exc_info=True, + ) + self._discard_owned_sweep_conn() + def run(self, *, install_signals: bool = True) -> None: """Lease-maintenance + recovery loop until stopped; releases on exit.""" self._running = True @@ -1077,6 +1201,10 @@ def run(self, *, install_signals: bool = True) -> None: # A transient DB blip must not tear the loop down — the lease # connection rolls back + discards a dead handle, and a failed # sweep discards its owned connection, so log and keep cycling. + # Counted: the heartbeat is stamped at tick START, so /health + # stays 200 through every-tick failures (schema/grant faults) — + # this counter is the only machine-readable signal for that. + self._metrics.tick_failures.inc() logger.exception("Reaper: cycle failed; continuing") time.sleep(self._interval) finally: @@ -1144,6 +1272,7 @@ def __init__(self, reaper: PgReaper, *, port: int, stale_after: float) -> None: check_name="pg_reaper_tick", age_key="seconds_since_last_tick", extra_status_fn=lambda: {"is_leader": reaper.is_leader}, + metrics_fn=reaper.metrics.render, thread_name="pg-reaper-liveness", log_label="pg-queue reaper", ) diff --git a/workers/tests/test_pg_metrics.py b/workers/tests/test_pg_metrics.py new file mode 100644 index 0000000000..7e125b2e2f --- /dev/null +++ b/workers/tests/test_pg_metrics.py @@ -0,0 +1,579 @@ +"""PG-queue application-level metrics exporter (UN-3672). + +Covers the four layers: +- the ``/metrics`` route on the shared LivenessServer (opt-in, isolated from + ``/health``), +- the metric definitions (``ConsumerMetrics`` / ``ReaperMetrics`` — instance + registries, atomic snapshot swap semantics), +- the reaper wiring (outcome counters threaded into the recovery/sweep + functions; the cadence-gated, best-effort queue-gauge refresh in ``tick``; + cadence reset on leadership step-down), +- the supervisor's fleet-metrics wiring (only producer of the fleet gauges). + +Float assertions use ``pytest.approx`` throughout — the values are exact +(set/inc of integers), but approx keeps the comparisons robust and quiet. +""" + +from __future__ import annotations + +import contextlib +import urllib.error +import urllib.request +from unittest.mock import MagicMock, patch + +import pytest + +import queue_backend.pg_queue.reaper as reaper_mod +from queue_backend.pg_queue.liveness import LivenessServer +from queue_backend.pg_queue.metrics import ( + METRICS_CONTENT_TYPE, + ConsumerMetrics, + ReaperMetrics, +) +from queue_backend.pg_queue.reaper import ( + PgReaper, + recover_expired_barriers, + refresh_queue_gauges, + sweep_orphan_claims, +) + +from .test_pg_reaper import _FakeLease + + +# Same stubs as test_pg_reaper's autouse fixtures (they're module-local there): +# tick() also runs the scheduler dispatch + retention sweeps, which would +# otherwise hit the dummy injected sweep_conn. +@pytest.fixture(autouse=True) +def stub_scheduler_and_sweeps(monkeypatch): + monkeypatch.setattr(reaper_mod, "dispatch_due_schedules", MagicMock(return_value=0)) + monkeypatch.setattr(reaper_mod, "sweep_expired_results", MagicMock(return_value=0)) + monkeypatch.setattr(reaper_mod, "sweep_orphan_dedup", MagicMock(return_value=0)) + monkeypatch.setattr(reaper_mod, "sweep_orphan_claims", MagicMock(return_value=0)) + + +def _get(url: str): + return urllib.request.urlopen(url, timeout=5) + + +def _sample(metrics, name: str, labels: dict[str, str] | None = None) -> float | None: + return metrics.registry.get_sample_value(name, labels or {}) + + +@contextlib.contextmanager +def _running(server): + server.start() + try: + yield server + finally: + server.stop() + + +class TestLivenessMetricsRoute: + """/metrics on the shared LivenessServer: opt-in, never touching /health.""" + + def _server(self, metrics_fn) -> LivenessServer: + return LivenessServer( + freshness_fn=lambda: 0.0, + stale_after=60, + port=0, + check_name="t", + age_key="age", + metrics_fn=metrics_fn, + ) + + def test_404_when_no_metrics_fn(self): + with _running(self._server(None)) as server: + with pytest.raises(urllib.error.HTTPError) as ei: + _get(f"http://127.0.0.1:{server.bound_port}/metrics") + assert ei.value.code == 404 + + def test_serves_prometheus_body_and_content_type(self): + with _running(self._server(lambda: b"pg_test_metric 1.0\n")) as server: + with _get(f"http://127.0.0.1:{server.bound_port}/metrics") as resp: + assert resp.status == 200 + assert resp.headers["Content-Type"] == METRICS_CONTENT_TYPE + assert resp.read() == b"pg_test_metric 1.0\n" + + def test_query_string_is_stripped(self): + # Mirrors the /health?probe=k8s behavior — self.path includes the query. + with _running(self._server(lambda: b"x 1\n")) as server: + with _get( + f"http://127.0.0.1:{server.bound_port}/metrics?scrape=test" + ) as resp: + assert resp.status == 200 + + def test_broken_metrics_fn_500s_but_health_still_answers(self): + # The whole point of sharing the server: a broken exporter must degrade + # to /metrics alone — the probe verdict is untouched. + def boom() -> bytes: + raise RuntimeError("render failed") + + with _running(self._server(boom)) as server: + base = f"http://127.0.0.1:{server.bound_port}" + with pytest.raises(urllib.error.HTTPError) as ei: + _get(f"{base}/metrics") + assert ei.value.code == 500 + with _get(f"{base}/health") as resp: + assert resp.status == 200 + + +class TestConsumerMetrics: + def test_heartbeat_gauge_tracks_freshness_fn(self): + age = 7.5 + metrics = ConsumerMetrics(freshness_fn=lambda: age) + assert _sample(metrics, "pg_consumer_heartbeat_age_seconds") == pytest.approx( + 7.5 + ) + age = 42.0 # live callback, not a captured constant + assert _sample(metrics, "pg_consumer_heartbeat_age_seconds") == pytest.approx( + 42.0 + ) + + def test_fleet_hooks_are_optional(self): + plain = ConsumerMetrics(freshness_fn=lambda: 0.0) + assert _sample(plain, "pg_consumer_alive_children") is None + + fleet = ConsumerMetrics( + freshness_fn=lambda: 0.0, + alive_children_fn=lambda: 3.0, + concurrency_fn=lambda: 4.0, + ) + assert _sample(fleet, "pg_consumer_alive_children") == pytest.approx(3.0) + assert _sample(fleet, "pg_consumer_configured_concurrency") == pytest.approx( + 4.0 + ) + + def test_render_is_prometheus_exposition(self): + body = ConsumerMetrics(freshness_fn=lambda: 1.0).render() + assert b"pg_consumer_heartbeat_age_seconds" in body + + def test_two_instances_do_not_collide(self): + # Instance registries: the workers tree can be imported under two module + # names (bare-``tasks`` quirk), so module-level collectors would + # double-register. Two instances must simply coexist. + ConsumerMetrics(freshness_fn=lambda: 0.0) + ConsumerMetrics(freshness_fn=lambda: 0.0) + + +def _reaper_metrics(*, leader: bool = True) -> ReaperMetrics: + return ReaperMetrics(heartbeat_fn=lambda: 1.0, is_leader_fn=lambda: leader) + + +class TestReaperMetrics: + def test_leadership_gauge(self): + assert _sample( + _reaper_metrics(leader=True), "pg_reaper_is_leader" + ) == pytest.approx(1.0) + assert _sample( + _reaper_metrics(leader=False), "pg_reaper_is_leader" + ) == pytest.approx(0.0) + + def test_snapshot_set_then_drained_queue_drops_out(self): + metrics = _reaper_metrics() + metrics.set_queue_snapshot( + depths={"q1": (5, 120.0), "q2": (1, 3.0)}, + barriers_live=2, + barriers_stranded=1, + ) + assert _sample(metrics, "pg_queue_depth", {"queue": "q1"}) == pytest.approx(5.0) + assert _sample( + metrics, "pg_queue_oldest_message_age_seconds", {"queue": "q1"} + ) == pytest.approx(120.0) + assert _sample(metrics, "pg_barrier_live") == pytest.approx(2.0) + assert _sample(metrics, "pg_barrier_stranded") == pytest.approx(1.0) + + # q1 drains: its series must DROP, not freeze at the last non-zero value. + metrics.set_queue_snapshot( + depths={"q2": (0, 0.0)}, barriers_live=0, barriers_stranded=0 + ) + assert _sample(metrics, "pg_queue_depth", {"queue": "q1"}) is None + assert _sample(metrics, "pg_queue_depth", {"queue": "q2"}) == pytest.approx(0.0) + + def test_clear_drops_series_and_zeroes_barriers(self): + # On losing leadership a standby must not export a frozen stale snapshot. + metrics = _reaper_metrics() + metrics.set_queue_snapshot( + depths={"q": (9, 10.0)}, barriers_live=1, barriers_stranded=1 + ) + metrics.clear_queue_snapshot() + assert _sample(metrics, "pg_queue_depth", {"queue": "q"}) is None + assert _sample(metrics, "pg_barrier_live") == pytest.approx(0.0) + + def test_gauges_age_grows_when_never_refreshed(self): + # The staleness metric must never report "fresh" in the maximal-staleness + # case: with no snapshot ever taken it grows from construction, so a + # leader whose refresh has failed since boot still trips an age alert. + metrics = _reaper_metrics() + age = _sample(metrics, "pg_queue_gauges_age_seconds") + assert age is not None and age >= 0.0 + metrics._queue_collector._snapshot = ( + metrics._queue_collector._snapshot.__class__( + reference_monotonic=metrics._queue_collector._snapshot.reference_monotonic + - 100.0 + ) + ) + aged = _sample(metrics, "pg_queue_gauges_age_seconds") + assert aged is not None and aged >= 100.0 + + def test_gauges_age_resets_on_snapshot_and_on_clear(self): + metrics = _reaper_metrics() + metrics.set_queue_snapshot(depths={}, barriers_live=0, barriers_stranded=0) + age = _sample(metrics, "pg_queue_gauges_age_seconds") + assert age is not None and 0.0 <= age < 5.0 + # clear() restarts the age from the step-down instant (not from boot). + metrics.clear_queue_snapshot() + age = _sample(metrics, "pg_queue_gauges_age_seconds") + assert age is not None and 0.0 <= age < 5.0 + + def test_collector_describe_lists_names_without_samples(self): + # Registration protocol: describe() must expose the same names as + # collect() (registry duplicate-checking) but with NO samples — so + # register() never runs the render path (clock read) as a side effect. + metrics = _reaper_metrics() + collector = metrics._queue_collector + described = {m.name: m for m in collector.describe()} + collected = {m.name for m in collector.collect()} + assert set(described) == collected + assert all(not m.samples for m in described.values()) + + def test_scrape_is_atomic_snapshot(self): + # The collector renders from ONE snapshot reference: a replace() during + # a render can't mix old and new values. Simulate by capturing the + # families from collect() and swapping mid-iteration. + metrics = _reaper_metrics() + metrics.set_queue_snapshot( + depths={"q": (5, 50.0)}, barriers_live=5, barriers_stranded=5 + ) + collector = metrics._queue_collector + families = list(collector.collect()) # snapshot read happens here + metrics.set_queue_snapshot( + depths={"q": (9, 90.0)}, barriers_live=9, barriers_stranded=9 + ) + by_name = {f.name: f for f in families} + depth = by_name["pg_queue_depth"].samples[0].value + live = by_name["pg_barrier_live"].samples[0].value + assert (depth, live) == (pytest.approx(5.0), pytest.approx(5.0)) + + +class _FakeCursor: + """Cursor returning one preloaded result set per execute() call, recording + each ``(sql, params)`` so tests can pin the SQL contract.""" + + def __init__(self, result_sets): + self._result_sets = list(result_sets) + self._current = None + self.executed: list[tuple[str, object]] = [] + + def execute(self, sql, params=None): + self.executed.append((sql, params)) + self._current = self._result_sets.pop(0) + + def fetchall(self): + return self._current + + def fetchone(self): + return self._current + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class _FakeConn: + def __init__(self, result_sets): + self.cursor_obj = _FakeCursor(result_sets) + self.commits = 0 + self.rollbacks = 0 + + def cursor(self): + return self.cursor_obj + + def commit(self): + self.commits += 1 + + def rollback(self): + self.rollbacks += 1 + + +class TestRefreshQueueGauges: + def test_snapshot_from_sql_rows(self): + metrics = _reaper_metrics() + conn = _FakeConn( + [ + [("file_processing", 7, 33.0), ("celery", 0, 0.0)], # depth rows + (4, 2), # barriers live, stranded + ] + ) + refresh_queue_gauges(conn, metrics, stuck_timeout_seconds=9000) + assert conn.commits == 1 + assert _sample( + metrics, "pg_queue_depth", {"queue": "file_processing"} + ) == pytest.approx(7.0) + assert _sample(metrics, "pg_barrier_live") == pytest.approx(4.0) + assert _sample(metrics, "pg_barrier_stranded") == pytest.approx(2.0) + + def test_sql_contract(self): + # Pin the queries: wrong table, dropped GROUP BY, a missing stranded + # predicate or unbound stuck-timeout would silently diverge the metric + # from what the reaper actually recovers. + metrics = _reaper_metrics() + conn = _FakeConn([[], (0, 0)]) + refresh_queue_gauges(conn, metrics, stuck_timeout_seconds=9000) + (depth_sql, depth_params), (barrier_sql, barrier_params) = ( + conn.cursor_obj.executed + ) + assert "pg_queue_message" in depth_sql + assert "GROUP BY queue_name" in depth_sql + assert depth_params is None + assert "pg_barrier_state" in barrier_sql + assert "remaining > 0" in barrier_sql + assert reaper_mod._STRANDED_PREDICATE in barrier_sql + assert barrier_params == (9000,) + + def test_error_rolls_back_and_reraises(self): + metrics = _reaper_metrics() + conn = MagicMock() + conn.cursor.side_effect = RuntimeError("db down") + with pytest.raises(RuntimeError): + refresh_queue_gauges(conn, metrics, stuck_timeout_seconds=9000) + conn.rollback.assert_called_once() + # No partial snapshot: the depth series stay absent. + assert _sample(metrics, "pg_queue_depth", {"queue": "any"}) is None + + +class TestOutcomeCounters: + """The recovery/sweep functions increment counters at their summary sites.""" + + def test_barrier_recovery_counts(self): + metrics = _reaper_metrics() + conn = _FakeConn([[("e1", "org", 1), ("e2", "org", 1)]]) + with patch.object( + reaper_mod, "_recover_one_barrier", side_effect=[True, RuntimeError("x")] + ): + recovered = recover_expired_barriers( + conn, api_client=object(), stuck_timeout_seconds=1, metrics=metrics + ) + assert recovered == ["e1"] + assert _sample(metrics, "pg_reaper_barrier_recovered_total") == pytest.approx( + 1.0 + ) + assert _sample( + metrics, "pg_reaper_barrier_recovery_failures_total" + ) == pytest.approx(1.0) + + def test_claim_sweep_counts(self): + metrics = _reaper_metrics() + conn = _FakeConn([[("e1", "org"), ("e2", "org")]]) + with patch.object( + reaper_mod, + "_recover_one_claim", + side_effect=[reaper_mod._CLAIM_RECOVERED, reaper_mod._CLAIM_GC], + ): + removed = sweep_orphan_claims( + conn, api_client=object(), stuck_timeout_seconds=1, metrics=metrics + ) + assert removed == 2 + assert _sample(metrics, "pg_reaper_claim_recovered_total") == pytest.approx(1.0) + assert _sample(metrics, "pg_reaper_claim_gc_total") == pytest.approx(1.0) + assert _sample( + metrics, "pg_reaper_claim_recovery_failures_total" + ) == pytest.approx(0.0) + + def test_counters_optional_no_metrics_kwarg(self): + # Celery-safety twin: existing callers that pass no metrics still work. + conn = _FakeConn([[]]) + assert ( + recover_expired_barriers(conn, api_client=object(), stuck_timeout_seconds=1) + == [] + ) + + +class TestReaperTickWiring: + def _reaper(self, lease, **kw) -> PgReaper: + return PgReaper( + lease, interval_seconds=0.01, sweep_conn=object(), api_client=object(), **kw + ) + + def test_leader_tick_refreshes_gauges(self): + reaper = self._reaper(_FakeLease(acquires=True)) + with ( + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + patch.object(reaper_mod, "refresh_queue_gauges") as refresh, + ): + reaper.tick() + # Pin the args: the reaper must feed ITS OWN exporter with ITS timeout. + refresh.assert_called_once_with( + reaper._sweep_conn, reaper.metrics, reaper._stuck_timeout_seconds + ) + + def test_refresh_is_cadence_gated_and_resumes(self): + reaper = self._reaper(_FakeLease(acquires=True)) + with ( + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + patch.object(reaper_mod, "refresh_queue_gauges") as refresh, + ): + reaper.tick() + reaper.tick() # within the refresh interval → no second refresh + assert refresh.call_count == 1 + # Resumption: once the interval elapses the gate must reopen — + # otherwise gauges silently freeze forever after the first snapshot. + reaper._last_gauge_refresh_monotonic -= ( + reaper_mod._GAUGE_REFRESH_INTERVAL_SECONDS + 1 + ) + reaper.tick() + assert refresh.call_count == 2 + + def test_refresh_failure_never_fails_the_tick_and_consumes_the_interval(self): + reaper = self._reaper(_FakeLease(acquires=True)) + with ( + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + patch.object( + reaper_mod, "refresh_queue_gauges", side_effect=RuntimeError("db") + ) as refresh, + ): + outcome = reaper.tick() # must not raise + assert outcome.was_leader is True + # Cadence advanced BEFORE the failed read: the immediate next tick + # must NOT retry (a persistent failure retries once per interval, + # not every tick). + reaper.tick() + assert refresh.call_count == 1 + assert _sample( + reaper.metrics, "pg_reaper_gauge_refresh_failures_total" + ) == pytest.approx(1.0) + + def test_standby_never_refreshes(self): + reaper = self._reaper(_FakeLease(acquires=False)) + with patch.object(reaper_mod, "refresh_queue_gauges") as refresh: + reaper.tick() + refresh.assert_not_called() + + def test_lost_leadership_clears_snapshot_and_resets_cadence(self): + reaper = self._reaper(_FakeLease(acquires=[True, True], renews=[False, True])) + with ( + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + patch.object(reaper_mod, "refresh_queue_gauges") as refresh, + ): + reaper.tick() # becomes leader, refresh #1 + reaper.metrics.set_queue_snapshot( + depths={"q": (1, 1.0)}, barriers_live=1, barriers_stranded=0 + ) + reaper.tick() # renew fails → steps down → snapshot cleared; + # re-acquires within the same tick and refreshes IMMEDIATELY — + # the cadence reset is load-bearing (else a re-elected leader + # exports a false "empty queue" for up to a full interval). + assert refresh.call_count == 2 + assert _sample(reaper.metrics, "pg_queue_depth", {"queue": "q"}) is None + + def test_renew_raise_clears_snapshot_before_propagating(self): + # The OTHER step-down path: renew() raising (lost DB mid-lease). The + # frozen snapshot must be cleared even though the tick re-raises. + lease = _FakeLease(acquires=True) + reaper = self._reaper(lease) + with ( + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + patch.object(reaper_mod, "refresh_queue_gauges"), + ): + reaper.tick() # becomes leader + reaper.metrics.set_queue_snapshot( + depths={"q": (3, 1.0)}, barriers_live=1, barriers_stranded=0 + ) + lease.renew = MagicMock(side_effect=RuntimeError("db gone")) + with pytest.raises(RuntimeError): + reaper.tick() + assert reaper.is_leader is False + assert _sample(reaper.metrics, "pg_queue_depth", {"queue": "q"}) is None + + def test_sweep_failure_increments_labeled_counter(self, monkeypatch): + reaper = self._reaper(_FakeLease(acquires=True)) + monkeypatch.setattr( + reaper_mod, "sweep_expired_results", MagicMock(side_effect=OSError("db")) + ) + with ( + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + patch.object(reaper_mod, "refresh_queue_gauges"), + ): + reaper.tick() # first leader tick sweeps immediately + assert _sample( + reaper.metrics, "pg_reaper_sweep_failures_total", {"table": "pg_task_result"} + ) == pytest.approx(1.0) + # A later successful sweep must NOT reset the counter (monotonic). + monkeypatch.setattr( + reaper_mod, "sweep_expired_results", MagicMock(return_value=0) + ) + reaper._last_sweep_monotonic = None # reopen the sweep cadence gate + with ( + patch.object(reaper_mod, "recover_expired_barriers", return_value=[]), + patch.object(reaper_mod, "refresh_queue_gauges"), + ): + reaper.tick() + assert _sample( + reaper.metrics, "pg_reaper_sweep_failures_total", {"table": "pg_task_result"} + ) == pytest.approx(1.0) + + def test_run_counts_tick_failures(self): + # The heartbeat is stamped at tick START, so /health stays 200 through + # every-tick failures — this counter is the only machine-readable signal. + reaper = self._reaper(_FakeLease(acquires=True)) + with patch.object( + reaper_mod, + "recover_expired_barriers", + side_effect=RuntimeError("SELECT failed"), + ): + original_sleep = reaper_mod.time.sleep + + def stop_after_first(_secs): + reaper._running = False + original_sleep(0) + + with patch.object(reaper_mod.time, "sleep", side_effect=stop_after_first): + reaper.run(install_signals=False) + assert _sample(reaper.metrics, "pg_reaper_tick_failures_total") == pytest.approx( + 1.0 + ) + + def test_metrics_served_on_liveness_port(self): + # End-to-end: the reaper's /metrics answers with its registry content. + from queue_backend.pg_queue.reaper import ReaperLivenessServer + + reaper = self._reaper(_FakeLease(acquires=True)) + with _running(ReaperLivenessServer(reaper, port=0, stale_after=60)) as server: + with _get(f"http://127.0.0.1:{server.bound_port}/metrics") as resp: + body = resp.read() + assert b"pg_reaper_is_leader" in body + assert b"pg_reaper_heartbeat_age_seconds" in body + + +class TestConsumerServerMetricsRoute: + def test_consumer_metrics_served(self): + from queue_backend.pg_queue.consumer import LivenessServer as ConsumerServer + from queue_backend.pg_queue.consumer import PgQueueConsumer + + consumer = PgQueueConsumer(["q"], client=MagicMock()) + with _running(ConsumerServer(consumer, port=0, stale_after=60)) as server: + with _get(f"http://127.0.0.1:{server.bound_port}/metrics") as resp: + assert resp.headers["Content-Type"] == METRICS_CONTENT_TYPE + assert b"pg_consumer_heartbeat_age_seconds" in resp.read() + + +class TestSupervisorMetricsRoute: + def test_supervisor_fleet_metrics_served(self, monkeypatch): + # The supervisor is the ONLY producer of the fleet gauges, and its + # lambdas defer to scrape time — a _Fleet API rename would pass import + # and 500 every scrape at runtime only. Exercise the real wiring. + from pg_queue_consumer.supervisor import _Fleet, _maybe_start_supervisor_health + + monkeypatch.setenv("WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT", "0") + fleet = _Fleet(2) + server = _maybe_start_supervisor_health(fleet) + assert server is not None + try: + with _get(f"http://127.0.0.1:{server.bound_port}/metrics") as resp: + body = resp.read() + assert b"pg_consumer_heartbeat_age_seconds" in body + assert b"pg_consumer_alive_children" in body + assert b"pg_consumer_configured_concurrency 2.0" in body + finally: + server.stop() diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py index e64469b1f3..f8459ee8a5 100644 --- a/workers/tests/test_pg_reaper.py +++ b/workers/tests/test_pg_reaper.py @@ -301,7 +301,7 @@ def test_scheduler_runs_after_recovery(self, stub_scheduler_tick): with patch.object( reaper_mod, "recover_expired_barriers", - side_effect=lambda *_: order.append("recover") or [], + side_effect=lambda *_, **__: order.append("recover") or [], ): reaper.tick() assert order == ["recover", "schedule"] @@ -409,9 +409,13 @@ def test_leader_sweeps(self, stub_retention_sweep): reaper.tick() stub_retention_sweep.results.assert_called_once_with(conn) stub_retention_sweep.dedup.assert_called_once_with(conn, 86400) - # Orphan-claim sweep (UN-3679) is wired with the api client + stuck-timeout. + # Orphan-claim sweep (UN-3679) is wired with the api client + stuck-timeout + # (+ the metrics exporter, so claim outcomes surface as counters). stub_retention_sweep.claims.assert_called_once_with( - conn, reaper._get_api_client(), reaper._stuck_timeout_seconds + conn, + reaper._get_api_client(), + reaper._stuck_timeout_seconds, + metrics=reaper.metrics, ) def test_standby_does_not_sweep(self, stub_retention_sweep): @@ -440,7 +444,7 @@ def test_runs_after_recovery_and_schedule( with patch.object( reaper_mod, "recover_expired_barriers", - side_effect=lambda *_: order.append("recover") or [], + side_effect=lambda *_, **__: order.append("recover") or [], ): reaper.tick() assert order == ["recover", "schedule", "sweep"] From 746f943e4fa62e9afc1a6b14a23cd53954125fb7 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:51:48 +0530 Subject: [PATCH 59/65] =?UTF-8?q?UN-3680=20[FIX]=20PG=20queue=20=E2=80=94?= =?UTF-8?q?=20seam-audit=20fixes:=20poison=20identity,=20ack/release=20ret?= =?UTF-8?q?ries,=20callback=20duplicate=20guard,=20compose=20VT=20(#2144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3680 [FIX] PG queue — seam-audit fixes: poison identity, ack/release retries, callback duplicate guard, compose VT Post-merge seam audit of the PG-queue epic surfaced interaction bugs between individually-reviewed features. Runtime (OSS) findings, all PG-gated with a Celery no-op proven by test. Chart-side fixes ship as a separate unstract-cloud PR (repo boundary). A1 (blocker) — poison drop couldn't identify orchestration messages. The backend dispatches async_execute_bin with execution_id POSITIONALLY (args[2]); the poison-drop identity reader only read kwargs, so a poisoned orchestration (a circuit-breaker-open drop, BEFORE the barrier is armed and BEFORE the claim is taken) had no barrier row and no claim row — invisible to every reaper sweep, a permanent silent strand. _pipeline_identity now takes a positional fallback keyed by task name (async_execute_bin: args[2]=execution_id, args[0]=org). A2 — consumer ack (client.delete) lacked the reconnect-retry every producer-side write already had. The consumer connection idles the whole task wall-clock, so the ack is the most likely statement to meet a PgBouncer-reaped connection; a lost ack redelivers an already-completed message (duplicate work / double-fired callback). One-shot reused-gate retry; the DELETE is idempotent so it is safe even in the ambiguous post-commit case (unlike send's at-least-once INSERT). A3 — release_orchestration_claim lacked the retry. It is a first-write-after-idle on the failure path whose raise the caller swallows, so an idle-reap left the claim committed and suppressed every redelivery. Wrapped in the existing _run_idempotent_pre_dispatch_write helper (the DELETE is idempotent). A4 — docker-compose PG consumers at the 30s vt default. The callback + both orchestrators + scheduler had no vt env → a task longer than 30s redelivered mid-run. Added vt/health-stale (env-overridable), mirroring the k8s chart: orchestrators 600/660, callback 3660/3720, scheduler 180/240. fileproc/executor left as-is (correctly sized to the executor RPC timeout, EXECUTOR_RESULT_TIMEOUT). L1 — orchestration-entry terminal skip (defense-in-depth). The claim tombstone normally blocks re-orchestrating a COMPLETED execution; if it was GC'd (e.g. a lowered stuck-timeout) a redelivery could re-arm/re-dispatch a finished execution (duplicate destination writes when use_file_history=False). Skip if already terminal, keeping the freshly-won claim so it re-establishes the tombstone. PG-gated; Celery has no redelivery so it is a no-op there. H1 (blocker) — the aggregating callback re-ran webhooks + subscription-usage billing wholesale on redelivery (the send commit-retry double-enqueue, an idle-reaped ack, a vt overrun). Added a terminal re-check that skips the side effects when a prior delivery already finalized the execution. PG-gated via a _pg_transport marker the barrier stamps on the PG callback dispatch (PG_TRANSPORT_CALLBACK_KWARG); the Celery .link path never injects it, so the guard is a strict no-op on Celery. Tests: 1223 unit + real-Postgres integration pass; pre-commit clean. Co-Authored-By: Claude Fable 5 * UN-3680 [FIX] Address PR review — Sonar IndexError, callback guard hardening (COMPLETED-only, reuse status, narrow the catch), test parity SonarCloud (Critical) + PR Review Toolkit + Greptile on #2144. - Sonar (Critical) — _positional_identity could IndexError on a short/non-list args. Now bounds- AND type-checks (isinstance + len <= max(idx)) before ANY indexing, returning (None, None) instead. Also drops the redundant `task_name` param (toolkit): it reads the payload's own `task_name` (can't drift from it, no silently-inert None default). +test for non-sequence args. - Callback duplicate guard (Greptile double-fetch + toolkit Medium + Low): * Greptile — the core guard fetched status a SECOND time though _extract_callback_parameters already fetched the execution. Now stores it on context.execution_status (set once at the both-paths convergence) and the guard reads that — no extra round-trip. Replaces the fetch-helper (and its broad `except Exception` that could silently degrade the guard on a programming error — the Medium) with a pure predicate _callback_already_ran. * Low (design edge) — gate on COMPLETED ONLY, not all terminal_values(): ERROR/STOPPED can be set by other paths (external stop, reaper), so the first legitimate callback for such an execution must still run its side effects. COMPLETED is the unambiguous "a prior callback finalized this". * DRY — shared _skipped_duplicate_callback() result for both callback sites. - pg_barrier comment (Low) — PG_TRANSPORT_CALLBACK_KWARG is IMPORTED by callback/tasks.py, not a re-typed literal; reworded (no drift possible). - compose (Low) — "mirrors the k8s chart" softened (no chart in this repo). - Tests (Low parity gaps): delete() gains non-connection-error-not-retried + retry-reraises-once (send() parity); core guard asserts the marker is popped before extraction and that the Celery path never skips. 1230 unit + integration pass; pre-commit clean. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- docker/docker-compose.yaml | 21 +++ workers/callback/tasks.py | 68 +++++++++ workers/general/tasks.py | 23 +++ workers/queue_backend/pg_barrier.py | 37 ++++- workers/queue_backend/pg_queue/client.py | 34 +++++ workers/queue_backend/pg_queue/consumer.py | 49 ++++++- .../tests/test_orchestration_idempotency.py | 79 +++++++++- workers/tests/test_pg_barrier.py | 59 ++++++++ .../tests/test_pg_callback_duplicate_guard.py | 136 ++++++++++++++++++ workers/tests/test_pg_queue_client.py | 98 +++++++++++++ workers/tests/test_pg_queue_consumer.py | 53 +++++++ 11 files changed, 651 insertions(+), 6 deletions(-) create mode 100644 workers/tests/test_pg_callback_duplicate_guard.py diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index be5bd42160..019c819ad6 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -599,6 +599,13 @@ services: - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=api_deployment - WORKER_PG_QUEUE_CONSUMER_QUEUE=celery_api_deployments - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + # async_execute_bin arms the barrier + dispatches batches, then returns — a + # bounded-minutes orchestration, and idempotency-guarded (UN-3671) so a vt + # overrun can't double-orchestrate. Without an explicit vt the consumer + # defaults to 30s and a slow dispatch redelivers mid-run. 600/660 mirrors the + # k8s deployment defaults (grace-equivalent handled by restart:unless-stopped here). + - WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=${WORKER_PG_ORCHESTRATOR_VT_SECONDS:-600} + - WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS=${WORKER_PG_ORCHESTRATOR_HEALTH_STALE_SECONDS:-660} labels: - traefik.enable=false volumes: @@ -628,6 +635,10 @@ services: - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=general - WORKER_PG_QUEUE_CONSUMER_QUEUE=celery - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + # Orchestration is bounded-minutes + claim-guarded (see orchestrator-api). + # Without an explicit vt it defaults to 30s. 600/660 matches our k8s deployment defaults (workerPg* values, separate chart repo). + - WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=${WORKER_PG_ORCHESTRATOR_VT_SECONDS:-600} + - WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS=${WORKER_PG_ORCHESTRATOR_HEALTH_STALE_SECONDS:-660} labels: - traefik.enable=false volumes: @@ -699,6 +710,12 @@ services: - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=callback - WORKER_PG_QUEUE_CONSUMER_QUEUE=file_processing_callback,api_file_processing_callback - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + # The aggregating callback (fan-in) runs after all batches: aggregate results, + # update status, push destination, notify. Without an explicit vt it defaults + # to 30s and a >30s callback redelivers — re-firing webhooks + subscription + # billing. 3660/3720 matches our k8s deployment defaults (separate chart repo). + - WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=${WORKER_PG_CALLBACK_VT_SECONDS:-3660} + - WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS=${WORKER_PG_CALLBACK_HEALTH_STALE_SECONDS:-3720} labels: - traefik.enable=false volumes: @@ -732,6 +749,10 @@ services: - WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE=scheduler - WORKER_PG_QUEUE_CONSUMER_QUEUE=scheduler - WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT=8090 + # Scheduled-trigger dispatch is quick (enqueue the execution). Without an + # explicit vt it defaults to 30s. 180/240 matches our k8s deployment defaults (separate chart repo). + - WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=${WORKER_PG_SCHEDULER_VT_SECONDS:-180} + - WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS=${WORKER_PG_SCHEDULER_HEALTH_STALE_SECONDS:-240} labels: - traefik.enable=false volumes: diff --git a/workers/callback/tasks.py b/workers/callback/tasks.py index 0532fb177f..bc618d55cb 100644 --- a/workers/callback/tasks.py +++ b/workers/callback/tasks.py @@ -8,6 +8,7 @@ from typing import Any from queue_backend import worker_task +from queue_backend.pg_barrier import PG_TRANSPORT_CALLBACK_KWARG # Import shared worker infrastructure from shared.api import InternalAPIClient @@ -66,6 +67,10 @@ def __init__(self): self.pipeline_data: dict[str, Any] | None = None self.api_client: InternalAPIClient | None = None self.file_executions: list[dict[str, Any]] = [] + # Execution status as of extraction (the source-of-truth fetch already made + # here) — lets the PG duplicate guard skip a redelivered callback without a + # second round-trip. + self.execution_status: str | None = None def _initialize_performance_managers(): @@ -788,6 +793,8 @@ def _extract_callback_parameters( context.workflow_id = execution_info.get( "workflow_id" ) or workflow_definition.get("workflow_id") + # Status from the same fetch, for the PG duplicate guard (no extra call). + context.execution_status = execution_info.get("status") # Use existing API detection from source_config (no additional API calls needed) is_api_deployment = source_config.get("is_api", False) @@ -1360,6 +1367,37 @@ def _track_subscription_usage_if_available( } +def _callback_already_ran(execution_status: str | None) -> bool: + """PG at-least-once duplicate guard: ``True`` if a previous delivery of this + aggregating callback already ran to completion. + + Gated on COMPLETED **only** — that status is set exclusively by a successful + callback, so it is the unambiguous "a prior callback finalized this" signal. + ERROR / STOPPED are deliberately excluded: they can be set by OTHER paths + (external stop, upstream error, the reaper), so treating them as "already ran" + would make the *first, legitimate* callback for such an execution skip its side + effects (incl. resource cleanup). A redelivered callback (the send commit-retry + double-enqueue, an idle-reaped ack, a vt overrun) of a COMPLETED execution must + skip — re-running re-fires customer webhooks and double-counts billing. + + A pure predicate over the status the caller already has (no fetch, no ``except`` + that could silently degrade the guard on a programming error). PG only — the + caller gates on the transport marker, so Celery never reaches here. + """ + return execution_status == ExecutionStatus.COMPLETED.value + + +def _skipped_duplicate_callback(execution_id: str) -> dict[str, Any]: + """Idempotent result for a PG callback skipped as a duplicate (shared by both + callback entry points so the contract can't drift). + """ + return { + "status": "skipped_duplicate_callback", + "execution_id": execution_id, + "duplicate_callback_skipped": True, + } + + def _process_batch_callback_core( task_instance, results, *args, **kwargs ) -> dict[str, Any]: @@ -1379,6 +1417,10 @@ def _process_batch_callback_core( # Initialize performance optimizations _initialize_performance_managers() + # PG at-least-once duplicate guard (see _callback_already_ran). + # Popped BEFORE parameter extraction so the marker never flows into the context. + is_pg = bool(kwargs.pop(PG_TRANSPORT_CALLBACK_KWARG, False)) + # Extract and validate all parameters using single source of truth context = _extract_callback_parameters(task_instance, results, kwargs) @@ -1401,6 +1443,18 @@ def _process_batch_callback_core( f"Starting batch callback processing for execution {context.execution_id}" ) + # Skip a redelivered callback whose execution a prior delivery already + # completed — don't re-fire webhooks / re-count billing. Reuses the + # status from _extract_callback_parameters' fetch (no extra call). + # (Returns through the outer finally, which closes the api_client.) + if is_pg and _callback_already_ran(context.execution_status): + logger.warning( + f"PG callback: execution {context.execution_id} already COMPLETED " + f"— a previous callback finalized it; skipping duplicate side " + f"effects (status update, subscription billing, webhooks)." + ) + return _skipped_duplicate_callback(context.execution_id) + try: # Use unified status determination with timeout detection (shared with API callback) aggregated_results, execution_status, expected_files = ( @@ -1575,6 +1629,8 @@ def process_batch_callback_api( execution_id = kwargs.get("execution_id") pipeline_id = kwargs.get("pipeline_id") organization_id = kwargs.get("organization_id") + # PG at-least-once duplicate guard marker (see _callback_already_ran). + is_pg = bool(kwargs.pop(PG_TRANSPORT_CALLBACK_KWARG, False)) if not execution_id: raise ValueError("execution_id is required in kwargs") @@ -1603,6 +1659,18 @@ def process_batch_callback_api( workflow_execution = execution_context.get("execution", {}) workflow = execution_context.get("workflow", {}) + # PG at-least-once duplicate guard: a redelivered callback whose execution a + # prior delivery already completed must skip its side effects. Reuses the + # status just fetched — no extra round-trip. (Returns through the finally + # below, which closes the api_client.) + if is_pg and _callback_already_ran(workflow_execution.get("status")): + logger.warning( + f"PG API callback: execution {execution_id} already COMPLETED — a " + f"previous callback finalized it; skipping duplicate side effects " + f"(status update, subscription billing, webhooks)." + ) + return _skipped_duplicate_callback(execution_id) + # Extract schema_name and workflow_id from context schema_name = organization_id # For API callbacks, schema_name = organization_id workflow_id = workflow_execution.get("workflow_id") or workflow.get("id") diff --git a/workers/general/tasks.py b/workers/general/tasks.py index e8cae96190..7200a65892 100644 --- a/workers/general/tasks.py +++ b/workers/general/tasks.py @@ -306,6 +306,29 @@ def async_execute_bin_general( workflow_id = execution_data.get("workflow_id") logger.info(f"Execution {execution_id} status: {current_status}") + # PG redelivery guard (defense-in-depth): the orchestration claim + # tombstone normally blocks re-orchestrating a COMPLETED execution, but + # if that tombstone was GC'd (e.g. the reaper's stuck-timeout was lowered + # to test) a redelivery could reach here, win a fresh claim, and re-arm + + # re-dispatch a finished execution — duplicate (costly) destination + # writes when use_file_history=False. Skip if already terminal, keeping + # the freshly-won claim so it re-establishes the tombstone. PG only: + # Celery has no redelivery, so the orchestrator only ever runs on a + # non-terminal execution — the check is a no-op there. + if ( + is_pg_transport(transport) + and current_status in ExecutionStatus.terminal_values() + ): + logger.warning( + f"Execution {execution_id} already terminal ({current_status}) " + f"at orchestration entry — skipping re-orchestration (a PG " + f"redelivery of a finished execution)." + ) + return { + "status": "skipped_terminal_execution", + "execution_id": execution_id, + "workflow_id": workflow_id, + } # Note: We allow PENDING executions to continue - they should process available files # NOTE: Concurrent executions are allowed - individual active files are filtered out diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index 65895c31f6..fab31c44de 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -191,6 +191,13 @@ def _cursor() -> Iterator[Any]: # errors the broad psycopg2.OperationalError catch also covers. _BARRIER_RETRY_BACKOFF_SECONDS: Final = 0.5 +# Callback kwarg the PG barrier stamps on the aggregating callback's dispatch so +# the callback can PG-gate its at-least-once duplicate guard (see +# _fire_barrier_callback). callback/tasks.py IMPORTS this symbol (not a re-typed +# literal), so the producer and consumer can't drift. Underscore-prefixed so it +# can't collide with a real task kwarg. +PG_TRANSPORT_CALLBACK_KWARG: Final = "_pg_transport" + # One retry for the NON-idempotent barrier DECREMENT — but only on an # execute-phase failure on a reused connection (see :func:`_apply_decrement`), # never on commit (ambiguous). Same one-shot bound as the idempotent write. @@ -427,13 +434,28 @@ def release_orchestration_claim(execution_id: str) -> None: execution). Deleting it on failure restores the retry/redelivery path. A successful orchestration does NOT call this — its claim persists as the post-completion tombstone. Safe when no row exists (deletes nothing). + + Stale-connection resilience: the claim is taken at orchestration START and + released only on the failure path, so the pg_barrier thread-local connection + has idled through the entire orchestration attempt — this DELETE is a + first-write-after-idle, the one most likely to meet a PgBouncer-reaped + connection, in exactly the scenario the release exists for. The caller + swallows a raise here (best-effort), so without a retry a single idle-reap + leaves the claim committed and suppresses every redelivery. The DELETE is + idempotent (deleting an absent row is a no-op), so it reuses the retry-once + helper verbatim. """ - with _cursor() as cur: + + def _op(cur: PgCursor) -> None: cur.execute( f"DELETE FROM {qualified('pg_orchestration_claim')} WHERE execution_id = %s", (execution_id,), ) + _run_idempotent_pre_dispatch_write( + _op, what=f"release orchestration claim [exec:{execution_id}]" + ) + def _dispatch_pg( task_name: str, @@ -756,12 +778,23 @@ def _fire_barrier_callback( - absent / anything else (legacy ``.link`` path): dispatch via ``current_app.signature(...).apply_async()`` — byte-identical to pre-9e, preserving the ``fairness_headers`` the producer attached. + + On the PG path the callback kwargs carry :data:`PG_TRANSPORT_CALLBACK_KWARG` + so the aggregating callback can PG-gate its at-least-once duplicate guard (the + callback is unguarded against the redelivery its own dispatch admits; the + Celery ``.link`` path never injects it, so the guard is a no-op there). """ if is_pg_transport(callback_descriptor.get("transport")): + # Copy (don't mutate the shared descriptor) + tag the PG transport so the + # callback can gate its duplicate guard on it. + pg_kwargs = { + **callback_descriptor["kwargs"], + PG_TRANSPORT_CALLBACK_KWARG: True, + } handle = _dispatch_pg( callback_descriptor["task_name"], args=[all_results], - kwargs=callback_descriptor["kwargs"], + kwargs=pg_kwargs, queue=callback_descriptor["queue"], fairness=_fairness_from_headers(callback_descriptor.get("fairness_headers")), ) diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py index e7dccf8099..3bc1c5c068 100644 --- a/workers/queue_backend/pg_queue/client.py +++ b/workers/queue_backend/pg_queue/client.py @@ -392,7 +392,41 @@ def delete(self, msg_id: int) -> bool: i.e. the work may be processed twice. Logged at DEBUG here; the consumer emits the contextual WARNING (it names the task), so this avoids a duplicate warning per double-run. + + Reconnect-retry (mirrors :meth:`send`): the consumer connection idles for + the ENTIRE wall-clock of the in-process task before this ack — minutes for + a file-processing batch — so the ack is the single statement most likely to + meet a PgBouncer-reaped connection. Without a retry the ack is lost and an + ALREADY-COMPLETED message redelivers at vt expiry: duplicate work (a re-run + of the leaf, or the sharp case — re-firing the aggregating callback's + webhooks + subscription-usage billing). We retry ONCE, on ``_CONN_DEAD_ + ERRORS`` and only when the failing connection was **reused** (a fresh conn + failing is a genuine error). Unlike :meth:`send`'s at-least-once INSERT this + DELETE is idempotent — a re-run can only no-op (the row is already gone), + never duplicate — so the retry is safe even in the ambiguous post-commit + case (it just returns ``False``, "already gone", within the contract above). """ + reused = self._conn is not None and self._owns_conn + try: + return self._delete_row(msg_id) + except _CONN_DEAD_ERRORS as exc: + if not reused: + raise + logger.warning( + "PG-queue: delete(msg_id=%s) failed with a connection-level error " + "on a reused cached connection (%s: %s); dropping it and retrying " + "the ack once so the completed message isn't redelivered", + msg_id, + type(exc).__name__, + exc, + exc_info=True, + ) + time.sleep(_SEND_RETRY_BACKOFF_SECONDS) + # _cursor already dropped the dead owned conn, so this reconnects. + return self._delete_row(msg_id) + + def _delete_row(self, msg_id: int) -> bool: + """One DELETE of a queue row by ``msg_id`` (see :meth:`delete`).""" with self._cursor() as cur: cur.execute( f"DELETE FROM {qualified('pg_queue_message')} WHERE msg_id = %s", diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index c356ecf7a9..55b23ee954 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -104,14 +104,52 @@ class _PoisonMarkOutcome(Enum): TRANSIENT = "transient" # backend down / client build failed → re-park +# Fire-and-forget tasks that carry their identity POSITIONALLY (in ``args``, not +# ``kwargs`` / ``_barrier_context``), mapped to ``(execution_id_index, +# organization_id_index)``. ``async_execute_bin`` is dispatched +# ``args=[schema_name, workflow_id, execution_id, hash_values]`` by the backend +# (workflow_helper._dispatch_orchestrator_task) — keep this map in step with that +# arg layout. Its poison drop happens BEFORE the barrier is armed AND before the +# orchestration claim is taken (a circuit-breaker-open drop, or repeated pre-claim +# failures), so the strand has NO ``pg_barrier_state`` row and NO +# ``pg_orchestration_claim`` row: it is invisible to every reaper sweep. Without +# recovering the execution_id here the poison drop can only bare-delete → the +# execution hangs EXECUTING forever with no handle. ``args[0]`` (org schema) +# doubles as the org. +_POSITIONAL_IDENTITY_ARGS: dict[str, tuple[int, int]] = { + "async_execute_bin": (2, 0), +} + + +def _positional_identity(payload: TaskPayload) -> tuple[str | None, str | None]: + """``(execution_id, organization_id)`` from a positional-args orchestration + payload, keyed off the payload's own ``task_name`` (no separate arg to drift + from it). ``(None, None)`` when the task doesn't carry identity positionally, or + ``args`` is not a sequence / is too short (defensive — a malformed payload must + not ``IndexError`` on the poison-drop path). See :data:`_POSITIONAL_IDENTITY_ARGS`. + """ + indices = _POSITIONAL_IDENTITY_ARGS.get(payload.get("task_name") or "") + if indices is None: + return (None, None) + args = payload.get("args") or [] + exec_idx, org_idx = indices + # Bounds- AND type-check before ANY indexing: a short or non-sequence ``args`` + # returns (None, None) rather than indexing (no IndexError/TypeError path). + if not isinstance(args, (list, tuple)) or len(args) <= max(exec_idx, org_idx): + return (None, None) + return (args[exec_idx], args[org_idx]) + + def _pipeline_identity(payload: TaskPayload) -> tuple[str | None, str]: """Best-effort ``(execution_id, organization_id)`` from a fire-and-forget payload, for marking the execution ERROR on a poison drop. - The identity lives in one of three places, tried in order: directly on a + The identity lives in one of four places, tried in order: directly on a callback payload's ``kwargs`` (the aggregating-callback case — the sharpest strand); on the injected ``_barrier_context``'s callback descriptor (a - pipeline header); or the org on the ``fairness`` payload. The callback- + pipeline header); the org on the ``fairness`` payload; or — for orchestration + messages, which pass identity POSITIONALLY — the task's ``args`` (see + :func:`_positional_identity`). The callback- descriptor dig goes through :func:`callback_recovery_identity` so it can't drift from the barrier abort site. Returns ``(None, "")`` when the payload isn't a pipeline message — nothing to mark. ``organization_id`` may be ``""`` @@ -123,13 +161,18 @@ def _pipeline_identity(payload: TaskPayload) -> tuple[str | None, str]: cb_execution_id, cb_org = callback_recovery_identity( barrier_ctx.get("callback_descriptor") or {} ) + pos_execution_id, pos_org = _positional_identity(payload) execution_id = ( - kwargs.get("execution_id") or barrier_ctx.get("execution_id") or cb_execution_id + kwargs.get("execution_id") + or barrier_ctx.get("execution_id") + or cb_execution_id + or pos_execution_id ) organization_id = ( kwargs.get("organization_id") or cb_org or (payload.get("fairness") or {}).get("org_id") + or pos_org ) return ( str(execution_id) if execution_id else None, diff --git a/workers/tests/test_orchestration_idempotency.py b/workers/tests/test_orchestration_idempotency.py index bbeb61682f..4768ad583a 100644 --- a/workers/tests/test_orchestration_idempotency.py +++ b/workers/tests/test_orchestration_idempotency.py @@ -16,7 +16,7 @@ import logging import os import threading -from unittest.mock import patch +from unittest.mock import MagicMock, patch import psycopg2 import pytest @@ -172,6 +172,83 @@ def test_release_called_when_claimed_and_work_fails(self): ) assert released == ["e-y"] # claimed then failed → released + def test_terminal_execution_skips_and_keeps_claim(self): + # L1: a PG redelivery that WON a fresh claim (the tombstone had been GC'd) + # but finds the execution already terminal must skip re-orchestration + # WITHOUT releasing the claim (re-establishing the tombstone), and never + # reach the status-update / arm / dispatch below the guard. + released: list[str] = [] + api_client = MagicMock() + api_client.get_workflow_execution.return_value = MagicMock( + success=True, + data={ + "execution": { + "status": tasks.ExecutionStatus.COMPLETED.value, + "workflow_id": "wf", + } + }, + ) + with patch.dict( + self._task_globals(), + { + "try_claim_orchestration": lambda _eid, _org: True, # won the claim + "release_orchestration_claim": lambda eid: released.append(eid), + }, + ): + with patch.object( + tasks.WorkerExecutionContext, + "setup_execution_context", + return_value=(MagicMock(), api_client), + ): + out = tasks.async_execute_bin_general( + schema_name="org", + workflow_id="wf", + execution_id="e-term", + hash_values_of_files={}, + transport="pg_queue", + ) + assert out["status"] == "skipped_terminal_execution" + assert released == [] # claim kept → tombstone re-established + api_client.update_workflow_execution_status.assert_not_called() # never re-armed + + def test_terminal_execution_not_skipped_on_celery(self): + # Celery has no redelivery, so the terminal guard is is_pg-gated → a no-op. + # A terminal status on the Celery path must NOT short-circuit: we prove the + # task runs PAST the guard by making the next step (tool validation, called + # right after the guard) raise a sentinel. Patched via the task's own + # globals — the bare-``tasks`` module the worker bootstrap loads, not + # ``general.tasks`` (the sys.path quirk; a module-attr patch would miss it). + api_client = MagicMock() + api_client.get_workflow_execution.return_value = MagicMock( + success=True, + data={ + "execution": { + "status": tasks.ExecutionStatus.COMPLETED.value, + "workflow_id": "wf", + } + }, + ) + + def _sentinel(**_kwargs): + raise RuntimeError("reached past the guard") + + with patch.dict( + self._task_globals(), {"validate_workflow_tool_instances": _sentinel} + ): + with patch.object( + tasks.WorkerExecutionContext, + "setup_execution_context", + return_value=(MagicMock(), api_client), + ): + with pytest.raises(RuntimeError, match="reached past the guard"): + tasks.async_execute_bin_general( + schema_name="org", + workflow_id="wf", + execution_id="e-term-celery", + hash_values_of_files={}, + transport="celery", + ) + # --- Layer 2: claim primitive against real Postgres --- diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index 9e6cd455bc..b49713a4e7 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -550,6 +550,44 @@ def test_schema_behind_raises_actionable_error(self, _clean_local, exc_cls): try_claim_orchestration("exec-1", "org-1") +class TestReleaseOrchestrationClaimRetry: + """``release_orchestration_claim`` is a first-write-after-idle on the failure + path whose raise the caller swallows — an un-retried idle-reap would leave the + claim committed and suppress every redelivery. Its DELETE is idempotent, so it + reuses the retry-once helper. + """ + + @pytest.fixture(autouse=True) + def _no_sleep(self, monkeypatch): + monkeypatch.setattr(pg_barrier.time, "sleep", lambda *_a, **_k: None) + + def test_retries_once_on_reaped_cached_conn(self, _clean_local, monkeypatch, caplog): + dead = _FakeConn( + execute_error=psycopg2.InterfaceError("connection already closed") + ) + healthy = _FakeConn() + pg_barrier._local.conn = dead + monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: healthy) + + with caplog.at_level("WARNING"): + pg_barrier.release_orchestration_claim("exec-1") + + assert dead.executes == 1 and healthy.executes == 1 # exactly one retry + assert dead.closed is True # stale conn discarded + assert healthy.commits == 1 # committed on the retry + assert "release orchestration claim" in caplog.text + + def test_non_connection_error_surfaces(self, _clean_local, monkeypatch): + # A real (non-connection) error must not be silently retried away — it + # propagates so the best-effort caller logs it. + pg_barrier._local.conn = _FakeConn(execute_error=ValueError("logic")) + monkeypatch.setattr( + pg_barrier, "create_pg_connection", lambda **_k: pytest.fail("no reconnect") + ) + with pytest.raises(ValueError, match="logic"): + pg_barrier.release_orchestration_claim("exec-1") + + # --- Layer 2: enqueue + link/abort with a real injected connection --- @@ -1316,6 +1354,27 @@ def test_fire_barrier_callback_pg_self_chains_via_dispatch(self): assert mock_dispatch.call_args.kwargs["backend"] is QueueBackend.PG assert mock_dispatch.call_args.kwargs["args"] == [[{"r": 1}]] + def test_fire_barrier_callback_pg_tags_transport_marker(self): + # The PG callback carries the _pg_transport marker so the aggregating + # callback can gate its at-least-once duplicate guard on it. The shared + # descriptor must NOT be mutated (a copy is dispatched). + descriptor = {**_CALLBACK, "transport": "pg_queue"} + with patch("queue_backend.dispatch.dispatch") as mock_dispatch: + mock_dispatch.return_value = MagicMock(id="pg-cb") + _fire_barrier_callback(descriptor, [{"r": 1}]) + dispatched = mock_dispatch.call_args.kwargs["kwargs"] + assert dispatched.get(pg_barrier.PG_TRANSPORT_CALLBACK_KWARG) is True + assert pg_barrier.PG_TRANSPORT_CALLBACK_KWARG not in descriptor["kwargs"] + + def test_fire_barrier_callback_legacy_omits_transport_marker(self): + # The Celery .link path must NOT inject the marker → the callback's PG + # guard stays a no-op on Celery (no redelivery to guard). + with patch("celery.current_app.signature") as sig: + sig.return_value.apply_async.return_value = MagicMock(id="celery-cb") + _fire_barrier_callback(_CALLBACK, [{"r": 1}]) + passed = sig.call_args.kwargs["kwargs"] + assert pg_barrier.PG_TRANSPORT_CALLBACK_KWARG not in passed + def test_fire_barrier_callback_pg_carries_fairness(self): # greptile: the PG callback must ride the producer's org/priority (parity # with the Celery path), reconstructed from the stored fairness_headers. diff --git a/workers/tests/test_pg_callback_duplicate_guard.py b/workers/tests/test_pg_callback_duplicate_guard.py new file mode 100644 index 0000000000..656067959a --- /dev/null +++ b/workers/tests/test_pg_callback_duplicate_guard.py @@ -0,0 +1,136 @@ +"""PG at-least-once duplicate guard for the aggregating callback (H1). + +The callback (``process_batch_callback`` / ``_api``) re-runs status update, +subscription-usage billing and customer webhooks wholesale; on the PG path it can +be REDELIVERED (the ``PgQueueClient.send`` commit-retry double-enqueue, an +idle-reaped ack, a vt overrun). When a prior delivery already COMPLETED the +execution, the redelivery must SKIP its side effects. The guard is PG-gated by the +``_pg_transport`` marker the barrier stamps on the PG dispatch, so the Celery +``.link`` path (no redelivery) is a strict no-op. +""" + +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock, patch + +import callback.tasks as cb +from queue_backend.pg_barrier import PG_TRANSPORT_CALLBACK_KWARG +from unstract.core.data_models import ExecutionStatus + + +class TestCallbackAlreadyRan: + """The pure predicate: COMPLETED only (a status a prior callback alone sets).""" + + def test_completed_is_true(self): + assert cb._callback_already_ran(ExecutionStatus.COMPLETED.value) is True + + @pytest.mark.parametrize( + "status", + [ + ExecutionStatus.EXECUTING.value, + ExecutionStatus.PENDING.value, + # ERROR / STOPPED can be set by OTHER paths → NOT "a prior callback ran". + ExecutionStatus.ERROR.value, + ExecutionStatus.STOPPED.value, + None, + ], + ) + def test_non_completed_is_false(self, status): + assert cb._callback_already_ran(status) is False + + +class TestCoreCallbackGuard: + """``_process_batch_callback_core`` short-circuits a PG duplicate before any + side effect (using the status the context already carries), and only on PG. + """ + + def _context(self, status): + ctx = MagicMock() + ctx.organization_id = "org-1" + ctx.api_client = MagicMock() + ctx.execution_id = "e1" + ctx.execution_status = status + return ctx + + def _run(self, *, is_pg: bool, status: str): + kwargs = {"execution_id": "e1"} + if is_pg: + kwargs[PG_TRANSPORT_CALLBACK_KWARG] = True + extract = MagicMock(return_value=self._context(status)) + with ( + patch.object(cb, "_initialize_performance_managers"), + patch.object(cb, "_extract_callback_parameters", extract), + patch.object(cb, "_determine_execution_status_unified") as determine, + ): + determine.side_effect = AssertionError("side effects must not run") + out = cb._process_batch_callback_core(MagicMock(), [], **kwargs) + return out, extract, determine + + def test_pg_duplicate_skips_side_effects(self): + out, extract, determine = self._run( + is_pg=True, status=ExecutionStatus.COMPLETED.value + ) + assert out["status"] == "skipped_duplicate_callback" + assert out["duplicate_callback_skipped"] is True + determine.assert_not_called() # no status update / billing / webhooks + # The marker must be popped BEFORE extraction — never leak into the context. + assert PG_TRANSPORT_CALLBACK_KWARG not in extract.call_args.args[2] + + def test_pg_non_completed_proceeds(self): + # Not COMPLETED → the guard lets the callback run (reaches the side-effect + # step, stubbed to raise as the proof-of-reach). + with pytest.raises(AssertionError, match="side effects must not run"): + self._run(is_pg=True, status=ExecutionStatus.EXECUTING.value) + + def test_pg_error_status_proceeds(self): + # ERROR is excluded (may be external) → the first callback must still run. + with pytest.raises(AssertionError, match="side effects must not run"): + self._run(is_pg=True, status=ExecutionStatus.ERROR.value) + + def test_celery_never_skips(self): + # No marker → guard skipped entirely; the callback proceeds even on a + # COMPLETED status — proving zero Celery regression. + with pytest.raises(AssertionError, match="side effects must not run"): + self._run(is_pg=False, status=ExecutionStatus.COMPLETED.value) + + +class TestApiCallbackGuard: + """``process_batch_callback_api`` short-circuits a PG duplicate using the + execution status it already fetches (no extra round-trip). + """ + + def _run(self, *, is_pg: bool, status: str): + api = MagicMock() + api.get_workflow_execution.return_value = MagicMock( + success=True, + data={ + "execution": {"status": status, "workflow_id": "wf"}, + "workflow": {"id": "wf"}, + }, + ) + kwargs = {"execution_id": "e1", "organization_id": "org-1", "pipeline_id": "p"} + if is_pg: + kwargs[PG_TRANSPORT_CALLBACK_KWARG] = True + task = MagicMock() + task.request.id = "t1" + with ( + patch.object(cb, "create_api_client", return_value=api), + patch.object(cb, "_determine_execution_status_unified") as determine, + ): + determine.side_effect = AssertionError("side effects must not run") + out = cb.process_batch_callback_api(task, [], **kwargs) + return out, determine + + def test_pg_duplicate_skips_side_effects(self): + out, determine = self._run(is_pg=True, status=ExecutionStatus.COMPLETED.value) + assert out["status"] == "skipped_duplicate_callback" + determine.assert_not_called() + + def test_pg_non_completed_proceeds(self): + with pytest.raises(AssertionError, match="side effects must not run"): + self._run(is_pg=True, status=ExecutionStatus.EXECUTING.value) + + def test_celery_never_skips(self): + with pytest.raises(AssertionError, match="side effects must not run"): + self._run(is_pg=False, status=ExecutionStatus.COMPLETED.value) diff --git a/workers/tests/test_pg_queue_client.py b/workers/tests/test_pg_queue_client.py index 280af5a5ae..988f4c2166 100644 --- a/workers/tests/test_pg_queue_client.py +++ b/workers/tests/test_pg_queue_client.py @@ -338,6 +338,104 @@ def test_reused_conn_non_connection_error_not_retried(self, monkeypatch): sleep.assert_not_called() +class TestDeleteReconnectRetry: + """``delete()``'s (ack) one-shot reconnect-retry — the systematic first-write- + after-idle site: the consumer connection idles for the whole task wall-clock, + so a reaped conn kills the ack and redelivers an ALREADY-COMPLETED message + (duplicate work / double-fired callback). Unlike ``send()`` the DELETE is + idempotent, so retrying is safe even post-commit. + """ + + @staticmethod + def _conn(*, execute_side_effect=None, rowcount=1): + cur = MagicMock() + if execute_side_effect is not None: + cur.execute.side_effect = execute_side_effect + cur.rowcount = rowcount + conn = MagicMock() + conn.closed = 0 + conn.cursor.return_value = _CursorCtx(cur) + return conn, cur + + @staticmethod + def _no_sleep(monkeypatch): + sleep = MagicMock() + monkeypatch.setattr("queue_backend.pg_queue.client.time.sleep", sleep) + return sleep + + @pytest.mark.parametrize( + "exc_type", [psycopg2.OperationalError, psycopg2.InterfaceError] + ) + def test_reused_stale_conn_retries_and_acks(self, monkeypatch, caplog, exc_type): + dead, _ = self._conn(execute_side_effect=exc_type("idle reap")) + fresh, fresh_cur = self._conn(rowcount=1) + factory = MagicMock(return_value=fresh) + monkeypatch.setattr("queue_backend.pg_queue.client.create_pg_connection", factory) + sleep = self._no_sleep(monkeypatch) + client = PgQueueClient() + client._conn = dead # cached (reused) connection + + with caplog.at_level(logging.WARNING, logger="queue_backend.pg_queue.client"): + assert client.delete(42) is True # ack landed on the retry + dead.close.assert_called_once() # stale conn discarded + factory.assert_called_once() # reconnected exactly once + sleep.assert_called_once_with(_SEND_RETRY_BACKOFF_SECONDS) + _, params = fresh_cur.execute.call_args.args + assert params == (42,) # the msg_id passed through to the retry + assert "retrying the ack once" in caplog.text + + def test_fresh_conn_failure_does_not_retry(self, monkeypatch): + dead, _ = self._conn(execute_side_effect=psycopg2.OperationalError("down")) + factory = MagicMock(return_value=dead) + monkeypatch.setattr("queue_backend.pg_queue.client.create_pg_connection", factory) + sleep = self._no_sleep(monkeypatch) + client = PgQueueClient() + + with pytest.raises(psycopg2.OperationalError): + client.delete(1) + factory.assert_called_once() # created once, NOT retried + sleep.assert_not_called() + + def test_injected_conn_not_retried(self, monkeypatch): + conn, _ = self._conn(execute_side_effect=psycopg2.OperationalError("dead")) + self._no_sleep(monkeypatch) + client = PgQueueClient(conn=conn) + + with pytest.raises(psycopg2.OperationalError): + client.delete(1) + conn.close.assert_not_called() # caller's connection untouched + + def test_reused_conn_non_connection_error_not_retried(self, monkeypatch): + # A logical error (not Operational/Interface) on a reused conn is not a + # stale-connection symptom → re-raise, no reconnect (parity with send()). + bad, _ = self._conn(execute_side_effect=RuntimeError("logic")) + factory = MagicMock() + monkeypatch.setattr("queue_backend.pg_queue.client.create_pg_connection", factory) + sleep = self._no_sleep(monkeypatch) + client = PgQueueClient() + client._conn = bad + + with pytest.raises(RuntimeError): + client.delete(1) + factory.assert_not_called() + sleep.assert_not_called() + + def test_retry_failure_reraises_once(self, monkeypatch): + # The retry is bounded to exactly one reconnect: if the reconnected conn + # also dies, re-raise (no loop) — parity with send(). + dead1, _ = self._conn(execute_side_effect=psycopg2.OperationalError("reap")) + dead2, _ = self._conn(execute_side_effect=psycopg2.OperationalError("still")) + factory = MagicMock(return_value=dead2) + monkeypatch.setattr("queue_backend.pg_queue.client.create_pg_connection", factory) + self._no_sleep(monkeypatch) + client = PgQueueClient() + client._conn = dead1 + + with pytest.raises(psycopg2.OperationalError): + client.delete(1) + factory.assert_called_once() # one reconnect only, no loop + + class TestCreatePgConnection: """Unit coverage for the connection factory (no real DB).""" diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index 5f7b12b753..38faa94640 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -215,6 +215,36 @@ def test_execution_without_org_returns_empty_org(self): payload = {"kwargs": {"execution_id": "e4"}} assert _pipeline_identity(payload) == ("e4", "") + def test_from_positional_orchestration_args(self): + # async_execute_bin passes identity POSITIONALLY: args=[schema, workflow, + # execution_id, hash]. The fallback keys off the payload's own task_name. + from queue_backend.pg_queue.consumer import _pipeline_identity + + payload = { + "task_name": "async_execute_bin", + "args": ["org-schema", "wf-1", "exec-9", {}], + "kwargs": {"pipeline_id": "p"}, + } + assert _pipeline_identity(payload) == ("exec-9", "org-schema") + # An unknown task_name leaves the positional map inert. + assert _pipeline_identity({**payload, "task_name": "some.other"}) == (None, "") + + def test_positional_short_args_no_indexerror(self): + # A short args list (missing the execution_id index) must not IndexError; + # it yields no identity — harmless, since with no execution_id the poison + # drop bare-deletes anyway (the org is only used alongside an execution_id). + from queue_backend.pg_queue.consumer import _pipeline_identity + + payload = {"task_name": "async_execute_bin", "args": ["org-schema"]} + assert _pipeline_identity(payload) == (None, "") + + def test_positional_non_sequence_args_no_crash(self): + # A non-list args (malformed payload) must not TypeError/IndexError. + from queue_backend.pg_queue.consumer import _pipeline_identity + + payload = {"task_name": "async_execute_bin", "args": {"not": "a list"}} + assert _pipeline_identity(payload) == (None, "") + class TestPoisonDropMarksExecution: """UN-3670: a poison drop with no reply channel marks the execution ERROR @@ -239,6 +269,29 @@ def test_marks_error_then_deletes(self, monkeypatch): client.delete.assert_called_once_with(5) # then dropped client.set_vt.assert_not_called() + def test_positional_orchestration_poison_marks_error(self, monkeypatch): + # H2 regression: a poisoned async_execute_bin carries execution_id + # POSITIONALLY (args[2]) with no _barrier_context and — since its poison + # precedes barrier arm + claim — no reaper handle at all. It must still + # recover its identity and mark ERROR, not bare-delete into a silent strand. + marks = [] + monkeypatch.setattr( + "queue_backend.pg_queue.recovery.mark_execution_error", + lambda client, eid, org, *, error_message: marks.append((eid, org)) or True, + ) + payload = { + "task_name": "async_execute_bin", + "args": ["org-schema", "wf-1", "exec-9", {}], + "kwargs": {"pipeline_id": "p"}, + } + client = MagicMock() + client.read.return_value = [_msg(7, payload, read_ct=6)] + PgQueueConsumer( + ["q"], client=client, api_client=MagicMock(), max_attempts=5 + ).poll_once() + assert marks == [("exec-9", "org-schema")] # recovered + marked + client.delete.assert_called_once_with(7) # then dropped + def test_reparks_when_mark_unconfirmed(self, monkeypatch): monkeypatch.setattr( "queue_backend.pg_queue.recovery.mark_execution_error", From 7cee90439947311410aff87a263216e907b43066 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:28:00 +0530 Subject: [PATCH 60/65] =?UTF-8?q?UN-3683=20[FIX]=20PG=20executor=20result?= =?UTF-8?q?=20backend=20=E2=80=94=20clear=20result=20on=20consume=20+=20sc?= =?UTF-8?q?hema-drift=20guard=20(#2147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3683 [FIX] PG executor result backend — clear result on consume + schema-drift guard Clear the executor RPC reply payload as soon as the blocking caller consumes it, so customer extraction data (PII) doesn't sit in pg_task_result for the whole 1h retention TTL — the window shrinks to read-and-clear latency. - Add PgResultBackend.forget(task_id): UPDATE ... SET result = NULL. Nulls the payload but keeps the row as a tombstone so an at-least-once redelivery can't re-insert it (store_result is ON CONFLICT DO NOTHING); expires_at is untouched so the reaper still deletes the empty row at TTL. - Wire it at the single consume point (PgClientQueueTransport.wait_for_result) right after the reply is read; skip on timeout. - Best-effort by contract — never raises: it runs after the caller already holds the result, inside PgExecutionDispatcher.dispatch's except->failure guard, so a cleanup miss can't fail a successful RPC. The TTL sweep is the backstop. - Flag off -> Celery path -> no pg_task_result row, zero change. Also add a DB-gated schema-drift guard. The workers issue raw psycopg2 SQL (Docker-decoupled from Django), so a model column rename/removal fails only at runtime. Assert every worker-required column (per-table manifest for all 7 QUEUE_TABLES) exists in the live schema (expected subset of actual — catches renames/removals, ignores safe additions), plus a guard that the manifest covers exactly QUEUE_TABLES. Tests: forget unit + DB-gated (null-keeps-row, absent-noop, redelivery-blocked, best-effort swallow), transport wiring (forgets after consume / not on timeout), schema-drift per-table + registry coverage. Co-Authored-By: Claude Fable 5 * UN-3683 [FIX] Address PR review — backend parity, schema-scoped drift query, error scrub, retry + docs/tests - Backend transport parity: DjangoQueueTransport.wait_for_result now clears result+error after consume (mirrors PgResultBackend.forget), so a backend- dispatched RPC no longer leaves the payload for the full TTL. Backend tests added. - Error channel scrub: _forget_sql now `SET result = NULL, error = ''` — failure text can embed document content too, so the PII scrub covers both channels. - Resilience: forget routes through the idempotent _store_with_reconnect so a transient dead-conn blip self-heals on the one-shot retry instead of silently costing the full TTL; a surviving failure is logged (alert on the string until a forget_failures metric is wired — deferred to the pg_queue metrics work). - Schema-drift query: pin `table_schema = queue_schema()`. information_schema.columns is privilege- not search_path-filtered, so an unqualified table_name match could union a stale sibling schema and mask a dropped column. Docstring corrected; the CI-enforcement claim is softened (runs on migrated dev/CI DBs with REQUIRE_PG_TESTS, not a prod merge gate); path pointers fixed to queue_backend/ (names pg_barrier.py); manifest reworded as the full model mirror. - Tests strengthened: expires_at-unchanged, error-channel scrub, caplog no-warning on absent + warning-on-failure (logged-and-swallowed contract), forget-before- __exit__ ordering, backend clear / no-clear-on-timeout. - Docs: result_backend module + get_result docstrings admit the consumed-tombstone third state; forget states the never-raises invariant instead of a hardcoded caller; call-site comment de-duplicated. Co-Authored-By: Claude Fable 5 * UN-3683 [FIX] Address Greptile — generalize _store_with_reconnect docstring for forget's UPDATE Routing forget() through _store_with_reconnect (previous commit) added a second, non-INSERT caller, but the docstring still justified unconditional-retry safety with the INSERT-only example. Generalize it: the retry is safe because EVERY write routed through here is idempotent — store_result's INSERT ON CONFLICT DO NOTHING and forget's UPDATE SET result = NULL, error = '' — with a note to keep that invariant if a third caller is added. Docs only; no behaviour change. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- backend/pg_queue/executor_rpc.py | 22 +++- backend/pg_queue/tests/test_executor_rpc.py | 22 ++++ .../queue_backend/pg_queue/executor_rpc.py | 4 + .../queue_backend/pg_queue/result_backend.py | 74 ++++++++++- workers/tests/test_executor_rpc.py | 29 +++++ workers/tests/test_pg_result_backend.py | 81 ++++++++++++ workers/tests/test_pg_schema_drift.py | 120 ++++++++++++++++++ 7 files changed, 346 insertions(+), 6 deletions(-) create mode 100644 workers/tests/test_pg_schema_drift.py diff --git a/backend/pg_queue/executor_rpc.py b/backend/pg_queue/executor_rpc.py index cd850a2440..1bed4a03d6 100644 --- a/backend/pg_queue/executor_rpc.py +++ b/backend/pg_queue/executor_rpc.py @@ -13,6 +13,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING from django.db import close_old_connections @@ -34,6 +35,8 @@ from unstract.core.data_models import ContinuationSpec from unstract.sdk1.execution.context import ExecutionContext +logger = logging.getLogger(__name__) + # Re-exported so existing ``from pg_queue.executor_rpc import …`` imports keep working. __all__ = [ "DjangoQueueTransport", @@ -99,7 +102,24 @@ def _fetch() -> ExecResultRow | None: return None return ExecResultRow(status=row.status, result=row.result, error=row.error) - return poll_for_row(_fetch, timeout, between_polls=close_old_connections) + row = poll_for_row(_fetch, timeout, between_polls=close_old_connections) + if row is not None: + # Reply consumed: clear the payload (result + error) so PII doesn't sit + # in pg_task_result for the full retention TTL — mirrors the workers + # transport's PgResultBackend.forget so both dispatch paths behave alike. + # Best-effort: runs after the caller holds the result, inside + # PgExecutionDispatcher.dispatch's never-raises guard, so a cleanup miss + # must not fail a good RPC; the reaper's retention sweep is the backstop. + try: + PgTaskResult.objects.filter(pk=reply_key).update(result=None, error="") + except Exception: + logger.warning( + "DjangoQueueTransport: could not clear pg_task_result for " + "reply_key=%s after consume; the retention sweep will flush it", + reply_key, + exc_info=True, + ) + return row def get_executor_dispatcher( diff --git a/backend/pg_queue/tests/test_executor_rpc.py b/backend/pg_queue/tests/test_executor_rpc.py index 610c45ee0b..b8171db36d 100644 --- a/backend/pg_queue/tests/test_executor_rpc.py +++ b/backend/pg_queue/tests/test_executor_rpc.py @@ -93,6 +93,28 @@ def test_multi_iteration_poll_misses_then_hits(self): cl.assert_called_once() # between_polls released the conn on the miss slp.assert_called_once() # slept once between the two polls + def test_present_row_clears_payload_after_consume(self): + # Mirrors the workers' PgResultBackend.forget: once the reply is read, both + # payload channels are nulled so PII doesn't sit for the full retention TTL. + row = MagicMock(status="completed", result={"a": 1}, error="") + qs = MagicMock() + qs.filter.return_value.first.return_value = row + with patch(f"{_MOD}.PgTaskResult", MagicMock(objects=qs)): + DjangoQueueTransport().wait_for_result("rk", timeout=5) + qs.filter.return_value.update.assert_called_once_with(result=None, error="") + + def test_timeout_does_not_clear(self): + # Nothing consumed on timeout — no clear (the executor may still write the + # row under this reply_key; the reaper sweeps the orphan at TTL). + qs = MagicMock() + qs.filter.return_value.first.return_value = None + with ( + patch(f"{_MOD}.PgTaskResult", MagicMock(objects=qs)), + patch(f"{_MOD}.close_old_connections"), + ): + DjangoQueueTransport().wait_for_result("rk", timeout=0) + qs.filter.return_value.update.assert_not_called() + class TestResolveExecutorTransport: def test_delegates_to_shared_flipt_resolver_true(self): diff --git a/workers/queue_backend/pg_queue/executor_rpc.py b/workers/queue_backend/pg_queue/executor_rpc.py index 892866a18b..373a331f77 100644 --- a/workers/queue_backend/pg_queue/executor_rpc.py +++ b/workers/queue_backend/pg_queue/executor_rpc.py @@ -96,6 +96,10 @@ def wait_for_result(self, reply_key: str, timeout: float) -> ExecResultRow | Non """ with PgResultBackend() as rb: row = rb.wait_for_result(reply_key, timeout) + if row is not None: + # Reply consumed: drop the payload now so PII doesn't sit in + # pg_task_result for the full retention TTL. Best-effort (see forget()). + rb.forget(reply_key) if row is None: return None return ExecResultRow( diff --git a/workers/queue_backend/pg_queue/result_backend.py b/workers/queue_backend/pg_queue/result_backend.py index 3185999978..c300c613d8 100644 --- a/workers/queue_backend/pg_queue/result_backend.py +++ b/workers/queue_backend/pg_queue/result_backend.py @@ -11,6 +11,11 @@ text if the task raised. Absence of a row means "not done yet"; there is deliberately no ``pending`` state to maintain. +Once the blocking caller consumes the reply, :meth:`PgResultBackend.forget` nulls +the payload in place — a third legal shape: ``completed``/``failed`` with +``result`` and ``error`` cleared (a tombstone the reaper deletes at +``expires_at``). Readers must not assume a finished row still carries a payload. + Two deliberate properties: - **Idempotent writes** (``INSERT … ON CONFLICT DO NOTHING``): the PG queue is @@ -83,6 +88,20 @@ def _get_sql() -> str: ) +def _forget_sql() -> str: + # Null BOTH payload channels but KEEP the row: ``result`` (success payload) and + # ``error`` (failure text — an extraction error can embed document content too), + # so the PII scrub covers the failure channel, not just the success one. The + # tombstone makes a redelivered ``store_result`` a ``ON CONFLICT DO NOTHING`` + # no-op (can't re-insert the payload); ``status`` still distinguishes the outcome + # and ``expires_at`` is left untouched so the reaper still deletes the row at + # retention. + return ( + f"UPDATE {qualified('pg_task_result')} " + "SET result = NULL, error = '' WHERE task_id = %s" + ) + + # Poll cadence for wait_for_result: start tight (low latency for fast tasks), # back off to a ceiling so a long-running task doesn't hammer the DB. _POLL_INITIAL_SECONDS = 0.2 @@ -142,7 +161,7 @@ def _cursor(self) -> Iterator[Any]: raise def _store_with_reconnect(self, operation: Callable[[Any], None]) -> None: - """Run an idempotent store ``operation(cur)`` in a committed cursor, + """Run an idempotent write ``operation(cur)`` in a committed cursor, retrying ONCE if the cached connection was reaped while idle. The executor consumer caches one ``PgResultBackend`` for its lifetime @@ -162,9 +181,13 @@ def _store_with_reconnect(self, operation: Callable[[Any], None]) -> None: executor consumer constructs ``PgResultBackend()``). Safe to retry unconditionally (no reused-vs-fresh guard needed, unlike - the non-idempotent ``PgQueueClient.send``): the write is - ``INSERT … ON CONFLICT (task_id) DO NOTHING``, so re-running after an - ambiguous failure can neither duplicate nor clobber a recorded result. + the non-idempotent ``PgQueueClient.send``) because **every** write routed + through here is idempotent: ``store_result``'s + ``INSERT … ON CONFLICT (task_id) DO NOTHING`` and ``forget``'s + ``UPDATE … SET result = NULL, error = ''`` (re-nulling an already-nulled + tombstone is a no-op). Re-running either after an ambiguous failure can + neither duplicate nor clobber a recorded result. Keep this invariant if a + third caller is added — a non-idempotent write must NOT use this helper. """ for attempt in range(1, _STORE_RETRY_ATTEMPTS + 1): try: @@ -217,7 +240,11 @@ def get_result(self, task_id: str) -> dict[str, Any] | None: """Return ``{status, result, error}`` if the row exists, else ``None``. ``result`` is the decoded JSONB dict (psycopg2 parses ``jsonb`` to a - Python ``dict``); ``None`` means the task has not finished yet. + Python ``dict``) or ``None``. A *missing* row (this returns ``None``) means + the task has not finished. A *present* row with ``result is None`` is either + a ``failed`` outcome or a ``completed`` **tombstone** whose payload was + already cleared by :meth:`forget` post-consume — ``status`` distinguishes + them, so a payload-poll must not treat a completed row as carrying a result. No reconnect-retry here (unlike :meth:`store_result`). The invariant it relies on: the sole entry point into a wait (``executor_rpc.wait_for_result`` @@ -258,6 +285,43 @@ def wait_for_result( maximum=_POLL_MAX_SECONDS, ) + def forget(self, task_id: str) -> None: + """Drop the stored payload once the blocking caller has consumed it. + + Nulls both payload columns (``result`` and ``error``) but keeps the row as a + tombstone (see :func:`_forget_sql`), so a redelivered executor message can't + re-insert the payload and the reaper still flushes the empty row at + ``expires_at``. Cutting the payload here shrinks the window that customer + extraction data (PII) sits in ``pg_task_result`` from the full retention TTL + down to the read-and-clear latency; the TTL sweep remains the backstop for + any result that is never consumed (caller crash / timeout). + + Runs through :meth:`_store_with_reconnect` because the UPDATE is idempotent + (same rationale as ``store_result``): a transient dead-connection blip + self-heals on the one-shot retry rather than silently costing the full TTL. + + **Best-effort by contract — never raises.** Every caller must sit under a + never-raises guard (today: ``PgExecutionDispatcher.dispatch``'s + ``except Exception -> failure``), because ``forget`` runs AFTER the caller + already holds the result — a raise here would turn a successful RPC into a + failure. A failure that survives the retry is logged and swallowed; the + retention sweep still bounds the exposure. A *systematic* failure (revoked + UPDATE privilege, recurring errors) is therefore observable only as a stream + of this WARNING — alert on the ``"could not clear result"`` string until a + ``forget_failures`` metric is wired (deferred to the pg_queue metrics work). + """ + try: + self._store_with_reconnect( + lambda cur: cur.execute(_forget_sql(), (str(task_id),)) + ) + except Exception: + logger.warning( + "PgResultBackend.forget: could not clear result for task_id=%s " + "after retry; the retention sweep will flush it at expires_at", + task_id, + exc_info=True, + ) + def close(self) -> None: """Close an owned connection (injected connections are the caller's).""" if self._owns_conn and self._conn is not None and not self._conn.closed: diff --git a/workers/tests/test_executor_rpc.py b/workers/tests/test_executor_rpc.py index e2bec43a62..ebedc4b9a0 100644 --- a/workers/tests/test_executor_rpc.py +++ b/workers/tests/test_executor_rpc.py @@ -323,6 +323,35 @@ def test_wait_for_result_none_passes_through(self): with patch(f"{_WMOD}.PgResultBackend", return_value=rb): assert PgClientQueueTransport().wait_for_result("rk", 5) is None + def test_wait_for_result_forgets_payload_after_consume(self): + # Once the reply is read, drop the payload so PII doesn't linger in + # pg_task_result for the full retention TTL (reaper is the backstop). + rb = MagicMock() + rb.__enter__.return_value = rb + rb.wait_for_result.return_value = { + "status": "completed", + "result": {"a": 1}, + "error": "", + } + with patch(f"{_WMOD}.PgResultBackend", return_value=rb): + PgClientQueueTransport().wait_for_result("rk", 5) + rb.forget.assert_called_once_with("rk") + # forget MUST run inside the `with` (before __exit__): moving it after the + # block would clear the payload against a reopened owned connection that + # nothing closes — a per-dispatch connection leak invisible to the mock. + names = [c[0] for c in rb.mock_calls] + assert names.index("forget") < names.index("__exit__") + + def test_wait_for_result_timeout_does_not_forget(self): + # On timeout nothing was consumed — leave the row for the reaper (the + # executor may still write it under this reply_key); do not clear. + rb = MagicMock() + rb.__enter__.return_value = rb + rb.wait_for_result.return_value = None + with patch(f"{_WMOD}.PgResultBackend", return_value=rb): + PgClientQueueTransport().wait_for_result("rk", 5) + rb.forget.assert_not_called() + def test_resolve_delegates_to_shared_flipt_resolver_true(self): with patch(f"{_WMOD}.resolve_pg_transport", return_value=True) as r: assert resolve_executor_transport(self._ctx()) is True diff --git a/workers/tests/test_pg_result_backend.py b/workers/tests/test_pg_result_backend.py index 10a565fc5b..c06cf34308 100644 --- a/workers/tests/test_pg_result_backend.py +++ b/workers/tests/test_pg_result_backend.py @@ -7,6 +7,7 @@ *another* connection (the cross-process request-reply path). """ +import logging import os import threading import time @@ -30,6 +31,15 @@ def _key() -> str: return f"{_MARK}-{uuid.uuid4()}" +def _expires_at(pg_conn, task_id): + with pg_conn.cursor() as cur: + cur.execute( + "SELECT expires_at FROM pg_task_result WHERE task_id = %s", (task_id,) + ) + row = cur.fetchone() + return row[0] if row else None + + @pytest.fixture def result_backend(pg_conn): rb = PgResultBackend(conn=pg_conn) @@ -106,6 +116,77 @@ def write_after_delay() -> None: assert row["result"] == {"late": True} +class TestForget: + """forget() drops the consumed payload but keeps the row as a tombstone, so a + redelivery can't re-insert it and the reaper still flushes it at expires_at. + """ + + def test_forget_nulls_result_keeps_row_and_preserves_expiry( + self, result_backend, pg_conn + ): + k = _key() + result_backend.store_result(k, result={"data": {"pii": "secret"}}) + before = _expires_at(pg_conn, k) + result_backend.forget(k) + row = result_backend.get_result(k) + assert row is not None # row kept (tombstone), not deleted + assert row["result"] is None # payload dropped + assert row["status"] == STATUS_COMPLETED # status preserved + # expires_at untouched — the reaper must still delete at the ORIGINAL TTL, + # so a future edit that also SETs expires_at would (correctly) fail this. + assert _expires_at(pg_conn, k) == before + + def test_forget_clears_error_channel_too(self, result_backend): + """Failed replies store error text that can embed document content, so the + PII scrub must clear ``error`` as well — not just the success payload. + """ + k = _key() + result_backend.store_result(k, error="boom: ") + result_backend.forget(k) + row = result_backend.get_result(k) + assert row is not None + assert row["error"] == "" # error text scrubbed + assert row["result"] is None + assert row["status"] == STATUS_FAILED # status still distinguishes outcome + + def test_forget_absent_is_clean_noop(self, result_backend, caplog): + """An absent row is a clean UPDATE-matches-nothing, NOT a swallowed error: + because forget() catches Exception, a broken UPDATE would look identical to + success — so pin that NO warning fires here (the UPDATE really ran). + """ + with caplog.at_level(logging.WARNING): + result_backend.forget(_key()) # must not raise + assert "could not clear result" not in caplog.text + + def test_forget_blocks_redelivery_repopulation(self, result_backend): + """After forget, an at-least-once redelivery of the executor message must + not bring the payload back — the tombstone wins ON CONFLICT DO NOTHING. + """ + k = _key() + result_backend.store_result(k, result={"pii": "first"}) + result_backend.forget(k) + result_backend.store_result(k, result={"pii": "again"}) # redelivery + assert result_backend.get_result(k)["result"] is None # stays cleared + + def test_forget_is_best_effort_logs_and_swallows(self, monkeypatch, caplog): + """Contract is *logged AND swallowed*: a failure that survives the retry + must not propagate (it runs inside dispatch's except->failure guard AFTER + the caller holds the result) AND must emit the WARNING — a silent + ``contextlib.suppress`` refactor would break the log-based alert and this + pins against it. + """ + rb = PgResultBackend() + monkeypatch.setattr( + rb, + "_store_with_reconnect", + MagicMock(side_effect=psycopg2.OperationalError("dead")), + ) + with caplog.at_level(logging.WARNING): + rb.forget("task-xyz") # must NOT raise + assert "could not clear result" in caplog.text + assert "task-xyz" in caplog.text + + # --- Unit: store_result reconnect-retry on a stale cached connection (UN-3659) --- diff --git a/workers/tests/test_pg_schema_drift.py b/workers/tests/test_pg_schema_drift.py new file mode 100644 index 0000000000..1bd4bd9f70 --- /dev/null +++ b/workers/tests/test_pg_schema_drift.py @@ -0,0 +1,120 @@ +"""Schema-drift guard for the PG-queue tables the workers hit with raw SQL. + +The workers speak to Postgres with **raw psycopg2 SQL, no Django ORM** — they run +in Docker decoupled from the backend by design, so they cannot import the models +as a source of truth. Column names therefore live as **string literals** across +``queue_backend/`` — ``queue_backend/pg_queue/*`` plus ``queue_backend/pg_barrier.py`` +(the raw SQL for ``pg_barrier_state`` / ``pg_orchestration_claim`` lives there, one +directory up). A change in ``backend/pg_queue`` that renames or drops one of those +columns would NOT fail at import/compile time — only at runtime as a SQL error. + +This DB-gated test closes that gap by asserting every column the workers depend on +still exists in the live schema. **Scope of enforcement:** it runs only against a +*migrated* Postgres — a dev machine with the ``pg_queue`` migration applied, or a CI +lane that provisions one and sets ``REQUIRE_PG_TESTS=1`` (conftest already turns the +``pg_conn`` skip into a failure there). The ``integration-workers`` CI lane is +currently ``optional`` and un-migrated, so ``pg_conn`` skips these there — wiring a +migrated lane is a follow-up. Until then, treat this as a dev/CI-lane guard, not a +production merge gate. + +Semantics — **expected ⊆ actual**: + * A renamed/removed column listed here → **fails** (the intended signal: update + the raw SQL in ``queue_backend/`` and this contract together). + * A newly *added* column → does **not** fail: the queue SQL uses explicit column + lists (never ``SELECT *``), so a new column can't break a worker. + +The manifest is the **full model mirror** of each queue-owned table (the workers +use ~all columns of these purpose-built tables; a couple — e.g. +``pg_periodic_schedule.created_at``/``updated_at`` — may not appear in any current +SQL, so the guard errs toward flagging any rename/removal on these tables for +review). Keep in sync only when a queue column is **renamed or removed**. +""" + +import pytest +from queue_backend.pg_queue.schema import QUEUE_TABLES, queue_schema + +# table -> columns the workers expect to exist (full model mirror; see module doc). +WORKER_SCHEMA_CONTRACT = { + "pg_queue_message": { + "msg_id", + "queue_name", + "message", + "org_id", + "enqueued_at", + "vt", + "read_ct", + "priority", + }, + "pg_task_result": { + "task_id", + "status", + "result", + "error", + "created_at", + "expires_at", + }, + "pg_barrier_state": { + "execution_id", + "organization_id", + "remaining", + "results", + "created_at", + "expires_at", + "last_progress_at", + }, + "pg_batch_dedup": {"execution_id", "batch_index", "created_at"}, + "pg_orchestration_claim": {"execution_id", "organization_id", "claimed_at"}, + "pg_orchestrator_lock": {"id", "leader", "acquired_at"}, + "pg_periodic_schedule": { + "pipeline_id", + "organization_id", + "workflow_id", + "pipeline_name", + "cron_string", + "enabled", + "pg_owned", + "last_run_at", + "next_run_at", + "created_at", + "updated_at", + }, +} + + +def _actual_columns(pg_conn, schema: str, table: str) -> set[str]: + """Columns present for *schema.table* in the live DB. + + ``information_schema.columns`` is **privilege-filtered, not search_path-filtered**, + so the schema is pinned explicitly: an unqualified ``table_name`` match would + union columns from every visible schema (e.g. a stale per-developer sibling + schema alongside ``unstract``) and mask a column dropped in the target schema. + The schema is ``queue_schema()`` — exactly what the workers' ``qualified()`` SQL + resolves to. + """ + with pg_conn.cursor() as cur: + cur.execute( + "SELECT column_name FROM information_schema.columns " + "WHERE table_schema = %s AND table_name = %s", + (schema, table), + ) + return {row[0] for row in cur.fetchall()} + + +def test_contract_covers_every_registered_queue_table(): + """The manifest must list exactly the tables the workers register in + ``QUEUE_TABLES`` — so adding a queue table forces a column contract for it and + the drift guard can never silently skip one.""" + assert set(WORKER_SCHEMA_CONTRACT) == set(QUEUE_TABLES) + + +@pytest.mark.parametrize("table", sorted(WORKER_SCHEMA_CONTRACT)) +def test_worker_required_columns_exist(pg_conn, table): + schema = queue_schema() # the schema the workers' qualified() SQL targets + actual = _actual_columns(pg_conn, schema, table) + assert actual, f"{schema}.{table} not found in the live schema (migration applied?)" + missing = WORKER_SCHEMA_CONTRACT[table] - actual + assert not missing, ( + f"{schema}.{table} is missing worker-required column(s) {sorted(missing)}: a " + f"model rename/removal in backend/pg_queue broke the workers' raw SQL. Update " + f"the SQL in queue_backend/ and WORKER_SCHEMA_CONTRACT together." + ) From f0342711054e92658be9279984a21691f42ee36d Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:19:42 +0530 Subject: [PATCH 61/65] =?UTF-8?q?UN-3684=20[FIX]=20PG=20barrier=20?= =?UTF-8?q?=E2=80=94=20claim=5Fbatch=20idle-reap:=20execute-phase=20reconn?= =?UTF-8?q?ect-retry=20(#2148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3684 [FIX] PG barrier — claim_batch idle-reap: execute-phase reconnect-retry Scheduled ETL executions on PG intermittently stranded ~2.5h then got reaper-marked ERROR. Root cause: the pg_barrier thread-local connection sits idle between infrequent scheduled batches (a pipeline fires one ~every 30 min, past PgBouncer's server_idle_timeout); the server reaps it, and the next batch's claim_batch runs its INSERT ... RETURNING on the stale handle -> "server closed the connection unexpectedly" (execute phase, nothing committed). claim_batch was on plain _cursor() with no reconnect-retry, so it failed outright; the batch never started and (fileproc vt ~= reaper timeout ~= 2.5h, no fast retry) it stranded until the reaper. This is the idle-reap family (UN-3654/3651/3659/3660) -- claim_batch is the un-hardened member. Give claim_batch the same execute-phase reconnect-retry its sibling try_claim_orchestration already has (phase-split): - reused-conn execute-phase death (idle reap) -> reconnect + retry once. Safe: the INSERT never committed, so the retry's win/lost RETURNING answer is authoritative. - commit-phase failure (ambiguous) -> NOT retried; propagate. A re-run could flip the claim (True->False -> skip-and-strand, or re-claim -> double-decrement). - fresh-conn failure -> NOT retried (a real DB error, not a reap). An idle-reaped batch connection now self-heals in ~0.5s at claim time -- the batch starts on first delivery, no 2.5h strand, the reaper never involved. Tests: TestClaimBatchRetry (execute-phase reaped-cached-conn retries once + returns the authoritative claim; commit-phase not retried; fresh-conn not retried). Co-Authored-By: Claude Fable 5 * UN-3684 [FIX] Address PR review — extract shared claim retry helper (Sonar dup) + docs/tests - Sonar 15.9% duplication: claim_batch's phase-split loop was a near-copy of try_claim_orchestration. Extract the shared retry into _run_returning_claim_with_reconnect(operation, what); claim_batch and try_claim_orchestration are now thin wrappers (the sibling's 5 tests confirm behavior is preserved). One loop, no dup. - reused-stale (latent): clear `reused = False` before the retry `continue`, so a fresh-conn death on a later attempt is never retried — structural, not merely masked by _BARRIER_WRITE_ATTEMPTS == 2 (fixes both callers via the helper). - Drop the unreachable "re-claim double-decrements" claim (docstring + comment + test docstring): the first attempt never returned True to the caller, so no path double-decrements; the strand hazard alone justifies never-retry. - Commit-phase WARNING now closes the loop for operators: if the row persisted, the redelivery's claim skips and the reaper recovers the barrier at expiry. - Fix two now-stale docstrings: _run_idempotent_pre_dispatch_write ("Same for claim_batch, whose RETURNING answer flips") and _BARRIER_WRITE_ATTEMPTS (also bounds the phase-split RETURNING claims). - Docstring precision: "idle between pipeline runs" (not "between batches"); keep the ~30-min incident detail in the test docstring, anchored to UN-3684. - Tests: retry-exhaustion (second conn-dead propagates, one retry), non-conn error on a reused conn NOT retried (pins the conn_dead guard), and the authoritative LOST answer after retry (new _FakeConn fetchone_result option). Suite: 94 passed. Co-Authored-By: Claude Fable 5 * UN-3684 [FIX] Address Sonar test duplication — parametrize the shared claim retry tests The source dedup (shared _run_returning_claim_with_reconnect) pushed Sonar's duplication into the test file (16.5%): TestClaimBatchRetry was a near-copy of TestClaimOrchestrationRetry. Collapse both into ONE parametrized TestReturningClaimReconnect over both RETURNING-claim callers (claim_batch, try_claim_orchestration), so each retry-behavior assertion is authored once. The try_claim_orchestration-specific schema-behind test stays in its own TestClaimOrchestrationSchemaGuard. Net coverage went UP (94 -> 97): the newer edge tests (retry-exhaustion, non-conn error not retried, authoritative lost answer) now run for try_claim_orchestration too, not just claim_batch. Co-Authored-By: Claude Fable 5 * UN-3684 [FIX] Address Greptile — cover InterfaceError in the claim execute-phase retry test The happy-path retry test only exercised OperationalError; an idle reap surfaces as InterfaceError ("connection already closed") at least as often (libpq detects the broken socket differently by timing). Parametrize the execute-phase-retry test over both _CONN_DEAD_ERRORS symptoms (mirrors TestDecrementPhaseSplitRetry), pinning that both are recognised and retried — now across both claim callers via the shared class. Suite: 99 passed. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- workers/queue_backend/pg_barrier.py | 166 ++++++++++++++++++---------- workers/tests/test_pg_barrier.py | 150 +++++++++++++++++++------ 2 files changed, 225 insertions(+), 91 deletions(-) diff --git a/workers/queue_backend/pg_barrier.py b/workers/queue_backend/pg_barrier.py index fab31c44de..137a40d769 100644 --- a/workers/queue_backend/pg_barrier.py +++ b/workers/queue_backend/pg_barrier.py @@ -183,7 +183,11 @@ def _cursor() -> Iterator[Any]: # dead conn, so a single retry runs against a freshly reconnected one. This turns # a transient blip (which previously aborted the whole execution at barrier # enqueue) into a self-heal. Kept a literal (not env-driven) so the idempotency -# bound can't be weakened operationally. +# bound can't be weakened operationally. Also bounds the phase-split RETURNING +# claims (``claim_batch`` / ``try_claim_orchestration`` via +# :func:`_run_returning_claim_with_reconnect`); they are non-idempotent but retry +# ONLY the provably-uncommitted execute phase, so the same one-retry bound holds +# without reopening a claim-flip. _BARRIER_WRITE_ATTEMPTS: Final = 2 # total attempts: 1 initial + 1 retry # Small fixed pause before the retry. The idle-reap case reconnects instantly # regardless; this only widens the self-heal window for a brief DB failover, and @@ -223,8 +227,11 @@ def _run_idempotent_pre_dispatch_write( remaining - 1``): it is not idempotent — a re-applied decrement can fire the callback **prematurely with incomplete results**, or skip past 0 and **strand the barrier** to expiry — so it stays on the plain :func:`_cursor` - (recover-but-don't-retry). Same for ``claim_batch``, whose ``RETURNING`` - answer flips on a retry. + (recover-but-don't-retry). The RETURNING claims (``claim_batch`` / + ``try_claim_orchestration``) are likewise non-idempotent — a committed claim's + win/lost answer flips on a naive re-run — so they retry via + :func:`_run_returning_claim_with_reconnect`, which retries ONLY the + provably-uncommitted execute phase. """ for attempt in range(1, _BARRIER_WRITE_ATTEMPTS + 1): try: @@ -261,6 +268,77 @@ def _delete_barrier(execution_id: str) -> None: ) +def _run_returning_claim_with_reconnect( + operation: Callable[[PgCursor], bool], *, what: str +) -> bool: + """Run a single ``INSERT … ON CONFLICT DO NOTHING RETURNING`` claim + (``operation(cur)`` → did-I-win), with the **phase-split reconnect-retry** + shared by :func:`claim_batch` and :func:`try_claim_orchestration`. + + These are the FIRST DB write of their task, so on an idle worker they most + often meet a PgBouncer-reaped *cached* connection. The claim is retried ONCE, + but ONLY on an **execute-phase** failure of a cached connection: the statement + never committed (rolled back on disconnect), so re-running lands exactly once + and the retry's ``RETURNING`` answer is authoritative — no win→lost flip. + + NOT retried (each propagates): + - **commit-phase** failure — AMBIGUOUS (the server may have committed). A re-run + could flip ``True`` → ``False`` → the caller skips and the barrier/claim + strands; and if the row *did* persist, the redelivery's claim likewise returns + ``False``, so the reaper recovers at expiry either way. + - **fresh-conn** failure — a real DB error; reconnecting buys nothing. + + (This is why the idempotent ``-> None`` pre-dispatch helper can't be reused: a + RETURNING claim is non-idempotent.) ``what`` is a caller-formatted log label, + e.g. ``[exec:] claim_batch()``. + """ + # Only a CACHED handle can be a stale idle-reap; sample before _get_conn() + # materialises one, so a fresh-conn failure isn't misread as a reap. + reused = getattr(_local, "conn", None) is not None and not _local.conn.closed + for attempt in range(1, _BARRIER_WRITE_ATTEMPTS + 1): + conn = _get_conn() + try: + with conn.cursor() as cur: + won = operation(cur) + except Exception as exc: + conn_dead = _recover_after_error(conn, exc) + if conn_dead and reused and attempt < _BARRIER_WRITE_ATTEMPTS: + logger.warning( + "%s: execute failed on a cached connection (%s) — reconnecting " + "and retrying once (attempt %d/%d); the INSERT never committed.", + what, + type(exc).__name__, + attempt, + _BARRIER_WRITE_ATTEMPTS, + exc_info=True, + ) + # The dead handle was discarded, so the next _get_conn() reconnects + # — clear `reused` so a fresh-conn death on a later attempt is never + # retried. Keeps the invariant structural, not merely masked by + # _BARRIER_WRITE_ATTEMPTS == 2. + reused = False + time.sleep(_BARRIER_RETRY_BACKOFF_SECONDS) + continue + raise + try: + conn.commit() + except Exception as exc: + _recover_after_error(conn, exc) + logger.warning( + "%s: commit failed (%s) — NOT retrying (the server may already have " + "committed; a re-run could flip the claim → the caller skips and the " + "barrier strands; if the row persisted, the redelivery skips and the " + "reaper recovers at expiry). Propagating.", + what, + type(exc).__name__, + exc_info=True, + ) + raise + return won + # Unreachable: the loop either returns or raises. + raise AssertionError(f"{what}: claim loop fell through") + + def claim_batch(execution_id: str, batch_index: int) -> bool: """Claim ``(execution_id, batch_index)`` for processing — the per-batch idempotency gate for the at-least-once PG pipeline. @@ -278,8 +356,16 @@ def claim_batch(execution_id: str, batch_index: int) -> bool: Called on the in-body PG path from :func:`run_batch_with_barrier` (claim at batch start), alongside the in-body decrement. The companion :func:`clear_execution_batches` reclaims the markers at barrier finalise. + + Runs through the shared phase-split reconnect-retry + (:func:`_run_returning_claim_with_reconnect`): the module's thread-local + connection sits idle **between pipeline runs** (a scheduled pipeline can idle + for tens of minutes, past PgBouncer's ``server_idle_timeout``), so the FIRST + ``INSERT`` of a run can hit an idle-reaped handle; that execute-phase reap + self-heals on one reconnect (UN-3684). See the helper for the full rationale. """ - with _cursor() as cur: + + def _op(cur: PgCursor) -> bool: cur.execute( f"INSERT INTO {qualified('pg_batch_dedup')} " "(execution_id, batch_index, created_at) " @@ -290,6 +376,10 @@ def claim_batch(execution_id: str, batch_index: int) -> bool: ) return cur.fetchone() is not None + return _run_returning_claim_with_reconnect( + _op, what=f"[exec:{execution_id}] claim_batch({batch_index})" + ) + def clear_execution_batches(execution_id: str) -> int: """Delete all per-batch dedup markers for an execution; returns the count. @@ -356,60 +446,22 @@ def try_claim_orchestration(execution_id: str, organization_id: str) -> bool: orchestrator marks ERROR and the redelivery re-orchestrates). This is why the idempotent-pre-dispatch helper (``-> None``, no RETURNING) can't be reused here. """ - # Sample cached-ness BEFORE _get_conn() below materialises a connection: only a - # cached handle can be a stale idle-reap, so a fresh-conn failure is a genuine - # DB error, not a reap (mirrors _apply_decrement / pg_queue.client.send). - reused = getattr(_local, "conn", None) is not None and not _local.conn.closed + + def _op(cur: PgCursor) -> bool: + cur.execute( + f"INSERT INTO {qualified('pg_orchestration_claim')} " + "(execution_id, organization_id, claimed_at) " + "VALUES (%s, %s, now()) " + "ON CONFLICT (execution_id) DO NOTHING " + "RETURNING execution_id", + (execution_id, organization_id), + ) + return cur.fetchone() is not None + try: - for attempt in range(1, _BARRIER_WRITE_ATTEMPTS + 1): - conn = _get_conn() - try: - with conn.cursor() as cur: - cur.execute( - f"INSERT INTO {qualified('pg_orchestration_claim')} " - "(execution_id, organization_id, claimed_at) " - "VALUES (%s, %s, now()) " - "ON CONFLICT (execution_id) DO NOTHING " - "RETURNING execution_id", - (execution_id, organization_id), - ) - won = cur.fetchone() is not None - except Exception as exc: - conn_dead = _recover_after_error(conn, exc) - # Retry ONLY a reused-conn death on the execute phase: it never - # committed, so the retry (on a fresh conn) is authoritative. - if conn_dead and reused and attempt < _BARRIER_WRITE_ATTEMPTS: - logger.warning( - "[exec:%s] orchestration claim: execute failed on a cached " - "connection (%s) — reconnecting and retrying once " - "(attempt %d/%d); the INSERT never committed.", - execution_id, - type(exc).__name__, - attempt, - _BARRIER_WRITE_ATTEMPTS, - exc_info=True, - ) - time.sleep(_BARRIER_RETRY_BACKOFF_SECONDS) - continue - raise - try: - conn.commit() - except Exception as exc: - # AMBIGUOUS commit — the row may be persisted. Re-running could flip - # a real winner to a loser (→ strand), so NEVER retry; propagate. - _recover_after_error(conn, exc) - logger.warning( - "[exec:%s] orchestration claim: commit failed (%s) — NOT " - "retrying (the server may already have committed; a re-run " - "could flip the winner). Propagating.", - execution_id, - type(exc).__name__, - exc_info=True, - ) - raise - return won - # Unreachable: the loop either returns or raises. - raise AssertionError("try_claim_orchestration loop fell through") + return _run_returning_claim_with_reconnect( + _op, what=f"[exec:{execution_id}] orchestration claim" + ) except (psycopg2.errors.UndefinedTable, psycopg2.errors.UndefinedColumn) as exc: # Fail fast with an actionable message instead of a generic per-execution # stack trace when the schema is behind: UndefinedTable (0012 missing) OR diff --git a/workers/tests/test_pg_barrier.py b/workers/tests/test_pg_barrier.py index b49713a4e7..bbedb58487 100644 --- a/workers/tests/test_pg_barrier.py +++ b/workers/tests/test_pg_barrier.py @@ -139,13 +139,21 @@ def test_missing_execution_id_raises(self): # --- Idempotent-write reconnect-retry (no DB) --- +_UNSET = object() + + class _FakeCursorCtx: - def __init__(self, on_execute): + def __init__(self, on_execute, fetchone_result=_UNSET): self._on_execute = on_execute + self._fetchone_result = fetchone_result def __enter__(self): cur = MagicMock(name="cursor") cur.execute.side_effect = self._on_execute + # Default (unset): fetchone() returns a truthy MagicMock (claim won). Pass + # fetchone_result=None to exercise the "lost" answer (claim already held). + if self._fetchone_result is not _UNSET: + cur.fetchone.return_value = self._fetchone_result return cur def __exit__(self, *exc): @@ -159,10 +167,11 @@ class _FakeConn: the ambiguous "reaped during commit" case the decrement must NOT retry. """ - def __init__(self, *, execute_error=None, commit_error=None): + def __init__(self, *, execute_error=None, commit_error=None, fetchone_result=_UNSET): self.closed = False self._execute_error = execute_error self._commit_error = commit_error + self._fetchone_result = fetchone_result self.commits = 0 self.rollbacks = 0 self.executes = 0 @@ -173,7 +182,7 @@ def _on_execute(*_a, **_k): if self._execute_error is not None: raise self._execute_error - return _FakeCursorCtx(_on_execute) + return _FakeCursorCtx(_on_execute, self._fetchone_result) def commit(self): self.commits += 1 @@ -469,14 +478,46 @@ def test_create_pg_connection_is_non_autocommit(): conn.close() -class TestClaimOrchestrationRetry: - """``try_claim_orchestration`` is a RETURNING claim, so — like the decrement, - and unlike the idempotent pre-dispatch write — it self-heals ONLY the - provably-safe case: an execute-phase failure on a *cached* connection (idle - reap; the INSERT never committed, so the retry's win/lost answer is - authoritative). A commit-phase failure is ambiguous and a fresh-conn failure - is a real error — neither is retried, so a real winner is never flipped to a - loser (which would strand the execution). Guards UN-3671's PR-review fix. +class TestClaimOrchestrationSchemaGuard: + """``try_claim_orchestration``-specific: a schema behind the code fails fast with + an actionable message. The shared execute-phase reconnect-retry is covered once, + over both claim callers, by :class:`TestReturningClaimReconnect`. + """ + + @pytest.mark.parametrize( + "exc_cls", + [psycopg2.errors.UndefinedTable, psycopg2.errors.UndefinedColumn], + ids=["table-missing (0012)", "column-missing (0013)"], + ) + def test_schema_behind_raises_actionable_error(self, _clean_local, exc_cls): + # A schema behind the code — 0012 not run (no table) OR 0012-but-not-0013 + # (table, no organization_id column) — must fail fast with an actionable + # message, NOT the generic per-execution stack trace, and NOT proceed. + pg_barrier._local.conn = _FakeConn(execute_error=exc_cls("schema behind")) + with pytest.raises(RuntimeError, match="schema is out of date"): + try_claim_orchestration("exec-1", "org-1") + + +@pytest.mark.parametrize( + "claim", + [ + pytest.param(lambda: claim_batch("exec-1", 0), id="claim_batch"), + pytest.param( + lambda: try_claim_orchestration("exec-1", "org-1"), + id="try_claim_orchestration", + ), + ], +) +class TestReturningClaimReconnect: + """The shared phase-split reconnect-retry + (``_run_returning_claim_with_reconnect``), exercised through BOTH RETURNING-claim + callers. Self-heals ONLY an execute-phase failure on a *cached* connection — the + idle reap that stranded scheduled ETL batches (UN-3684: a barrier connection idle + ~30 min between pipeline runs, past ``server_idle_timeout``; the INSERT never + committed, so the retry's win/lost answer is authoritative). NOT retried, for two + distinct reasons: a **commit-phase** failure is ambiguous — a re-run could flip + ``True``→``False`` and strand; a **fresh-conn** failure is a real DB error — + reconnecting buys nothing. """ @pytest.fixture @@ -485,18 +526,25 @@ def sleeps(self, monkeypatch): monkeypatch.setattr(pg_barrier.time, "sleep", calls.append) return calls + @pytest.mark.parametrize( + "exc_cls", + [psycopg2.OperationalError, psycopg2.InterfaceError], + ids=["OperationalError", "InterfaceError"], + ) def test_execute_phase_reaped_cached_conn_retries_once( - self, _clean_local, monkeypatch, caplog, sleeps + self, claim, exc_cls, _clean_local, monkeypatch, caplog, sleeps ): - dead = _FakeConn( - execute_error=psycopg2.OperationalError("server closed the connection") - ) + # Both _CONN_DEAD_ERRORS symptoms of an idle reap must be recognised and + # retried: OperationalError ("server closed …") and InterfaceError + # ("connection already closed") — libpq surfaces the broken socket either + # way depending on timing (mirrors TestDecrementPhaseSplitRetry). + dead = _FakeConn(execute_error=exc_cls("connection already closed")) healthy = _FakeConn() pg_barrier._local.conn = dead # cached → "reused" monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: healthy) with caplog.at_level("WARNING"): - try_claim_orchestration("exec-1", "org-1") + assert claim() is True # authoritative answer taken from the retry assert dead.executes == 1 and healthy.executes == 1 # exactly one extra try assert dead.commits == 0 # the reaped attempt never committed @@ -506,9 +554,11 @@ def test_execute_phase_reaped_cached_conn_retries_once( assert sleeps == [pg_barrier._BARRIER_RETRY_BACKOFF_SECONDS] assert "execute failed on a cached connection" in caplog.text - def test_commit_phase_failure_is_not_retried(self, _clean_local, monkeypatch, sleeps): - # Ambiguous commit (the server may have committed) → NOT retried, or a real - # winner could be flipped to a loser. Propagates; no reconnect. + def test_commit_phase_failure_is_not_retried( + self, claim, _clean_local, monkeypatch, sleeps + ): + # Ambiguous commit (server may have committed) → NOT retried; a re-run could + # flip the answer and strand. Propagates; no reconnect. conn = _FakeConn( commit_error=psycopg2.OperationalError("server closed during commit") ) @@ -519,12 +569,12 @@ def test_commit_phase_failure_is_not_retried(self, _clean_local, monkeypatch, sl lambda **_k: pytest.fail("must not reconnect on a commit-phase failure"), ) with pytest.raises(psycopg2.OperationalError): - try_claim_orchestration("exec-1", "org-1") + claim() assert conn.executes == 1 and conn.commits == 1 # tried once, no retry assert sleeps == [] # no backoff → no retry def test_fresh_conn_execute_failure_is_not_retried( - self, _clean_local, monkeypatch, sleeps + self, claim, _clean_local, monkeypatch, sleeps ): # A freshly-created connection failing is a real DB error, not an idle reap # (reused is False when _local.conn starts empty) → not retried. @@ -532,22 +582,54 @@ def test_fresh_conn_execute_failure_is_not_retried( pg_barrier._local.conn = None # → reused False monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: dead) with pytest.raises(psycopg2.OperationalError): - try_claim_orchestration("exec-1", "org-1") + claim() assert dead.executes == 1 # no retry assert sleeps == [] - @pytest.mark.parametrize( - "exc_cls", - [psycopg2.errors.UndefinedTable, psycopg2.errors.UndefinedColumn], - ids=["table-missing (0012)", "column-missing (0013)"], - ) - def test_schema_behind_raises_actionable_error(self, _clean_local, exc_cls): - # A schema behind the code — 0012 not run (no table) OR 0012-but-not-0013 - # (table, no organization_id column) — must fail fast with an actionable - # message, NOT the generic per-execution stack trace, and NOT proceed. - pg_barrier._local.conn = _FakeConn(execute_error=exc_cls("schema behind")) - with pytest.raises(RuntimeError, match="schema is out of date"): - try_claim_orchestration("exec-1", "org-1") + def test_second_conn_dead_failure_propagates( + self, claim, _clean_local, monkeypatch, sleeps + ): + # Retry is bounded to ONE: a cached reap retries onto a fresh conn that also + # dies → propagate (don't loop, don't hit the AssertionError fall-through). + dead1 = _FakeConn(execute_error=psycopg2.OperationalError("reaped")) + dead2 = _FakeConn(execute_error=psycopg2.OperationalError("still down")) + pg_barrier._local.conn = dead1 # cached → reused + monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: dead2) + with pytest.raises(psycopg2.OperationalError): + claim() + assert dead1.executes == 1 and dead2.executes == 1 # exactly one retry + assert sleeps == [pg_barrier._BARRIER_RETRY_BACKOFF_SECONDS] + + def test_non_connection_execute_error_on_reused_conn_not_retried( + self, claim, _clean_local, monkeypatch, sleeps + ): + # The `conn_dead` conjunct is the only guard against retrying a real SQL + # error on a cached conn — a regression dropping it would silently re-run a + # DataError. Pin: reused conn, non-conn error → NOT retried. + bad = _FakeConn(execute_error=psycopg2.DataError("invalid byte 0x00")) + pg_barrier._local.conn = bad # cached → reused True + monkeypatch.setattr( + pg_barrier, + "create_pg_connection", + lambda **_k: pytest.fail("must not reconnect on a non-connection error"), + ) + with pytest.raises(psycopg2.DataError): + claim() + assert bad.executes == 1 # no retry + assert sleeps == [] + + def test_retry_returns_the_lost_answer_authoritatively( + self, claim, _clean_local, monkeypatch, sleeps + ): + # The "win/LOST answer is authoritative" half: after a reaped-conn retry, a + # fresh conn whose RETURNING yields no row → claim already held → returns + # False (not the truthy MagicMock default the happy-path test can only see). + dead = _FakeConn(execute_error=psycopg2.OperationalError("reaped")) + healthy = _FakeConn(fetchone_result=None) # ON CONFLICT DO NOTHING: no row + pg_barrier._local.conn = dead + monkeypatch.setattr(pg_barrier, "create_pg_connection", lambda **_k: healthy) + assert claim() is False # authoritative "lost" from the retry + assert healthy.commits == 1 class TestReleaseOrchestrationClaimRetry: From 7d29d53ad43847b8dae15a7b8c59ef38f698d470 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:08:52 +0530 Subject: [PATCH 62/65] =?UTF-8?q?UN-3690=20[FIX]=20PG=20scheduler=20?= =?UTF-8?q?=E2=80=94=20recompute=20next=5Frun=5Fat=20on=20cron=20edit=20(B?= =?UTF-8?q?eat=20parity)=20(#2149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3690 [FIX] PG scheduler — recompute next_run_at on cron edit (Beat parity) Editing a pg_owned pipeline's schedule time didn't take effect until one stale cycle: the pipeline fired once more at the OLD cron's next_run_at, ignoring the new time. Confirmed on integration via GCP logs (pipeline 438ac6d8: baselined to 04:00 off the original `0 * * * *`, edits to 12/52/58 ignored, fired stale at 04:00 → only then recomputed off the new cron). Root: mirror_periodic_schedule_upsert upserts cron_string but never touches next_run_at, which the PG scheduler fires on. Beat has no staleness — it recomputes due-ness live from the crontab each tick — so this is a PG-only Beat-parity regression, flag-gated (pg_owned). Fix: when the cron CHANGES on an already-baselined row (next_run_at IS NOT NULL), retarget next_run_at to the new cron's next match. Left untouched for a new row (NULL → scheduler's no-burst baseline) and a Beat→PG hand-over (ownership nulls it). The `next_run_at IS NOT NULL` guard means the branch only ever fires for a row the PG scheduler has already baselined — i.e. an actively PG-owned schedule — so Celery-only rows are never touched. Still best-effort (swallowed), and never touches the Beat PeriodicTask/CrontabSchedule. Tests: 5 new mirror cases (retarget-on-change, no-touch-same-cron, NULL-left-for-baseline, new-row-no-set, invalid-cron-still-upserts); existing suite green (17). Also dev-tested end-to-end against a real DB (sqlite in-memory) exercising the real ORM + croniter, all 5 scenarios pass incl. the Celery-untouched (NULL-stays-NULL) property. Co-Authored-By: Claude Fable 5 * UN-3690 [FIX] Address PR review — guard the recompute so it can't break the base mirror - [Important] Structural-regression fix: extract the existing-row read + recompute into _retargeted_next_run_at(), fully guarded, returning None on any failure — so a read/parse failure degrades to exactly the pre-PR behaviour (plain cron_string/enabled upsert) instead of the outer swallow taking down the mandatory mirror write. New test: read failure → no raise + update_or_create still called. - [Important] Log the ACTUAL exception (exc + exc_info) instead of a generic "could not recompute" — a bad read, cron=None (AttributeError), CroniterBadCronError/BadDateError, or an API change are now distinguishable. - Comment accuracy: name PipelineSerializer.validate_cron_string (pipeline_v2) as the upstream guard; correct the Beat→PG hand-over note (next_run_at is already NULL there — ownership only clears it on rollback to Beat). - Type: defaults: dict[str, Any]. - Tests: shared _stub_existing_row() helper (deep .filter().values().first() chain now in one place); pin the retarget assertion to a literal constant (datetime(2026,1,1,10,0,utc)) + cron_string, not a croniter self-echo. 18 unit tests pass; real-DB dev-test (sqlite) still green on all 5 scenarios. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- backend/scheduler/tasks.py | 73 +++++++++++-- .../tests/test_pg_periodic_schedule_mirror.py | 102 ++++++++++++++++++ 2 files changed, 168 insertions(+), 7 deletions(-) diff --git a/backend/scheduler/tasks.py b/backend/scheduler/tasks.py index 86cf8a7a16..f0a008663d 100644 --- a/backend/scheduler/tasks.py +++ b/backend/scheduler/tasks.py @@ -1,9 +1,11 @@ import json import logging import traceback +from datetime import datetime from typing import Any from celery import shared_task +from croniter import croniter from django.db import transaction from django.utils import timezone from django_celery_beat.models import CrontabSchedule, PeriodicTask @@ -32,6 +34,49 @@ # --------------------------------------------------------------------------- +def _retargeted_next_run_at(pipeline_id: str, cron_string: str) -> datetime | None: + """The ``next_run_at`` a cron EDIT should set for Beat parity, or ``None`` to + leave it as the PG scheduler's baseline owns it. + + Returns a recomputed next-match ONLY when ``cron_string`` differs from an + already-baselined mirror row (``next_run_at`` set). Returns ``None`` for a + brand-new row (no mirror yet) and a not-yet-baselined one (``next_run_at`` + NULL) so the scheduler's no-burst baseline records the first next-time. + + Fully guarded on purpose: this is an OPTIONAL enhancement over the mandatory + ``cron_string``/``enabled`` mirror write, so a read failure, a ``None``/invalid + cron (normally rejected upstream by ``PipelineSerializer.validate_cron_string`` + in ``pipeline_v2``), or a croniter error must degrade to ``None`` — never take + the base mirror write down with it. + """ + try: + existing = ( + PgPeriodicSchedule.objects.filter(pipeline_id=pipeline_id) + .values("cron_string", "next_run_at") + .first() + ) + if ( + existing is None + or existing["next_run_at"] is None + or existing["cron_string"] == cron_string + ): + return None + return croniter(cron_string, timezone.now()).get_next(datetime) + except Exception as exc: + # Log the ACTUAL cause (bad read; cron=None → AttributeError; + # CroniterBadCronError / CroniterBadDateError; a croniter API change) — a + # generic "could not recompute" is a dead end when debugging a stale fire. + logger.warning( + "pg_periodic_schedule: could not retarget next_run_at for pipeline %s " + "(cron %r): %s", + pipeline_id, + cron_string, + exc, + exc_info=True, + ) + return None + + def mirror_periodic_schedule_upsert( *, pipeline_id: str, @@ -42,15 +87,29 @@ def mirror_periodic_schedule_upsert( enabled: bool, ) -> None: try: + defaults: dict[str, Any] = { + "organization_id": organization_id or "", + "workflow_id": workflow_id or None, + "pipeline_name": pipeline_name or "", + "cron_string": cron_string, + "enabled": enabled, + } + # Beat parity: a cron EDIT on an already-baselined row must retarget + # next_run_at to the new cron's next match, else the PG scheduler fires + # once more at the STALE old-cron time — Beat has no such staleness, it + # recomputes due-ness live from the crontab each tick (UN-3690). Left + # untouched (NULL) for a brand-new row — the scheduler's no-burst baseline + # is correct there (a new schedule fires at its next match) — and for a + # Beat→PG hand-over, where next_run_at is already NULL (never set while + # Beat-owned; ownership clears it only on rollback to Beat, see + # reconcile_ownership_for). Computed via a fully-guarded helper so it can + # never take down the mandatory cron_string/enabled mirror write below. + next_run_at = _retargeted_next_run_at(pipeline_id, cron_string) + if next_run_at is not None: + defaults["next_run_at"] = next_run_at PgPeriodicSchedule.objects.update_or_create( pipeline_id=pipeline_id, - defaults={ - "organization_id": organization_id or "", - "workflow_id": workflow_id or None, - "pipeline_name": pipeline_name or "", - "cron_string": cron_string, - "enabled": enabled, - }, + defaults=defaults, ) except Exception: logger.exception( diff --git a/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py b/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py index ea56ccea5b..6b8a6cbb73 100644 --- a/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py +++ b/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py @@ -9,6 +9,8 @@ """ from contextlib import nullcontext +from datetime import datetime +from datetime import timezone as dt_timezone from unittest.mock import MagicMock, patch from scheduler import tasks @@ -24,9 +26,18 @@ class _DoesNotExist(Exception): """Stand-in for PeriodicTask.DoesNotExist when the model is mocked.""" +def _stub_existing_row(sched, row=None): + """Stub the ``.filter().values().first()`` read of the existing mirror row + (``None`` = no row yet). The one place the read shape lives, so a change to how + the code reads the row (e.g. ``.only()`` instead of ``.values()``) is a + one-line edit here.""" + sched.objects.filter.return_value.values.return_value.first.return_value = row + + class TestUpsertMirror: def test_upserts_with_given_fields(self): with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + _stub_existing_row(sched) tasks.mirror_periodic_schedule_upsert( pipeline_id=_PIPELINE_ID, organization_id=_ORG, @@ -46,6 +57,7 @@ def test_upserts_with_given_fields(self): def test_disabled_pipeline_mirrors_enabled_false(self): with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + _stub_existing_row(sched) tasks.mirror_periodic_schedule_upsert( pipeline_id=_PIPELINE_ID, organization_id=_ORG, @@ -58,6 +70,7 @@ def test_disabled_pipeline_mirrors_enabled_false(self): def test_failure_is_swallowed(self): with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + _stub_existing_row(sched) sched.objects.update_or_create.side_effect = RuntimeError("db down") # Must not raise. tasks.mirror_periodic_schedule_upsert( @@ -70,6 +83,95 @@ def test_failure_is_swallowed(self): ) +class TestUpsertNextRunRecompute: + """UN-3690 — a cron EDIT must retarget ``next_run_at`` (Beat parity), so the + new time takes effect this cycle instead of the pipeline firing once more at + the stale old-cron time. Only for an already-baselined row: a fresh row and a + Beat→PG hand-over keep ``next_run_at`` NULL for the scheduler's no-burst + baseline. + """ + + _OLD = datetime(2026, 1, 1, 9, 0, tzinfo=dt_timezone.utc) + + @staticmethod + def _mock_existing(sched, *, cron_string, next_run_at): + _stub_existing_row( + sched, + None + if cron_string is None + else {"cron_string": cron_string, "next_run_at": next_run_at}, + ) + + @staticmethod + def _upsert(cron): + tasks.mirror_periodic_schedule_upsert( + pipeline_id=_PIPELINE_ID, + organization_id=_ORG, + workflow_id=_WORKFLOW_ID, + pipeline_name=_REAL_NAME, + cron_string=cron, + enabled=True, + ) + + @staticmethod + def _defaults(sched): + return sched.objects.update_or_create.call_args.kwargs["defaults"] + + def test_cron_change_on_baselined_row_retargets_next_run_at(self): + now = datetime(2026, 1, 1, 8, 0, tzinfo=dt_timezone.utc) + with ( + patch("scheduler.tasks.PgPeriodicSchedule") as sched, + patch("scheduler.tasks.timezone.now", return_value=now), + ): + self._mock_existing(sched, cron_string="0 9 * * *", next_run_at=self._OLD) + self._upsert("0 10 * * *") # edited to a new time + defaults = self._defaults(sched) + # Assert the concrete constant (not a croniter self-echo) — pins both the + # value AND tz-awareness: the next 10:00 UTC after 08:00, off the NEW cron. + assert defaults["next_run_at"] == datetime(2026, 1, 1, 10, 0, tzinfo=dt_timezone.utc) + assert defaults["cron_string"] == "0 10 * * *" # retarget ships with new cron + + def test_unchanged_cron_leaves_next_run_at_untouched(self): + with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + self._mock_existing(sched, cron_string="0 9 * * *", next_run_at=self._OLD) + self._upsert("0 9 * * *") # same cron (e.g. a rename) + assert "next_run_at" not in self._defaults(sched) + + def test_null_next_run_at_left_for_baseline(self): + # Not-yet-baselined row (fresh mirror / Beat→PG hand-over) → keep NULL so + # the scheduler baseline records the first next-time (no double-retarget). + with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + self._mock_existing(sched, cron_string="0 9 * * *", next_run_at=None) + self._upsert("0 10 * * *") + assert "next_run_at" not in self._defaults(sched) + + def test_new_row_does_not_set_next_run_at(self): + with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + self._mock_existing(sched, cron_string=None, next_run_at=None) # → None + self._upsert("0 10 * * *") + assert "next_run_at" not in self._defaults(sched) + + def test_invalid_cron_leaves_stale_next_run_at_but_still_upserts(self): + # An unparseable cron must not block the mirror write; next_run_at is left + # as-is (the scheduler quiesces a bad cron on its own tick). + with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + self._mock_existing(sched, cron_string="0 9 * * *", next_run_at=self._OLD) + self._upsert("not a cron") + assert sched.objects.update_or_create.called + assert "next_run_at" not in self._defaults(sched) + + def test_read_failure_degrades_to_plain_upsert(self): + # The optional next_run_at recompute must NEVER take down the mandatory + # cron_string/enabled mirror write: a failure in the existing-row read + # degrades to exactly the pre-PR behaviour (plain upsert, no next_run_at) + # and never raises (the "never break Beat" guarantee) — UN-3690. + with patch("scheduler.tasks.PgPeriodicSchedule") as sched: + sched.objects.filter.side_effect = RuntimeError("db down") + self._upsert("0 10 * * *") # must not raise + assert sched.objects.update_or_create.called # base mirror write still ran + assert "next_run_at" not in self._defaults(sched) + + class TestHelperWiringSourcesRealName: """The High contract: the mirror must store the user-facing pipeline name, NOT the synthetic ``"Pipeline job-"`` label that the PeriodicTask args From be5909a9975e482dc9c45803eb9a8af2210e43e7 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:05:59 +0530 Subject: [PATCH 63/65] =?UTF-8?q?UN-3693=20[FIX]=20PG=20=E2=80=94=20Prompt?= =?UTF-8?q?=20Studio=20task=5Fstatus=20resolves=20from=20pg=5Ftask=5Fresul?= =?UTF-8?q?t=20(consumer-writes)=20(#2150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UN-3693 [FIX] PG — Prompt Studio task_status resolves from pg_task_result (consumer-writes, flag-gated) Under pg_queue_enabled, Prompt Studio's dispatch_with_callback tasks never register a Celery task, so the REST task_status endpoint (AsyncResult → celery_taskmeta) reported "processing" forever even though the work succeeded — hanging any REST-polling client (the flag-ON regression suite died at createEtlProject setup → 0 tests). Browser UI unaffected (Socket.IO completion event). PG-native, gated by the same per-org pg_queue_enabled flag the executor dispatch uses: - Write (one site): the PG consumer records the terminal status in pg_task_result from _chain_continuation — the single choke point for both success (error None, executor result) and failure (error set) of a callback dispatch — keyed by the dispatch task_id. completed unless the run raised or the executor reported success=False; before the enqueue so it resolves even if the callback is lost; TTL 24h; best-effort (never wedges the ack). PG-only by construction (the PG consumer only runs for flag-on-dispatched tasks). - Read (backend task_status): flag ON → read pg_task_result (completed/failed/ none→processing, DB-read guarded so a blip degrades to processing not a bare 500); flag OFF (default) → Celery AsyncResult unchanged, PG never queried. Clean table separation: PG tasks live entirely in pg_task_result; Celery tasks live entirely in celery_taskmeta; neither path bleeds into the other. No change to the Celery flow (flag-off is the original AsyncResult code; ide_callback untouched). Note: completed rows are status-only (PII-free); failed rows carry the executor error text (already shown to the owner via the socket event + task_status response). Tests: consumer (completed / executor-failed / run-raised / no-id / swallow / records- even-if-enqueue-fails); backend flag-gate (on completed/failed/pending/DB-blip; off celery + PG-untouched); DB-gated store_result(result={}) → completed guard. Co-authored-by: Claude Fable 5 --- .../tests/test_task_status.py | 104 ++++++++++++++++++ .../prompt_studio_core_v2/views.py | 48 ++++++++ workers/queue_backend/pg_queue/consumer.py | 71 ++++++++++++ workers/tests/test_pg_queue_consumer.py | 77 +++++++++++++ workers/tests/test_pg_result_backend.py | 11 ++ 5 files changed, 311 insertions(+) create mode 100644 backend/prompt_studio/prompt_studio_core_v2/tests/test_task_status.py diff --git a/backend/prompt_studio/prompt_studio_core_v2/tests/test_task_status.py b/backend/prompt_studio/prompt_studio_core_v2/tests/test_task_status.py new file mode 100644 index 0000000000..d7ee67c22b --- /dev/null +++ b/backend/prompt_studio/prompt_studio_core_v2/tests/test_task_status.py @@ -0,0 +1,104 @@ +"""UN-3693: PromptStudioCoreView.task_status — flag-gated. + +Gate on the per-org ``pg_queue_enabled`` flag (the same the executor dispatch uses): +flag ON → read ``pg_task_result`` (completed / failed / none→processing); flag OFF +(default) → Celery ``AsyncResult`` unchanged, and the PG table is never queried on +the hot poll path. ``check_feature_flag_status`` already fails closed to ``False`` on +any Flipt error, so "flag off / Flipt down" is one path. A transient PG DB error +degrades to "processing", not a bare 500. + +NOTE: run via a Django-bootstrapped harness (no pytest-django in this repo yet — the +CI-gating of these view tests is tracked in UN-3692). Verified locally green. +""" + +from unittest.mock import MagicMock, patch + +from prompt_studio.prompt_studio_core_v2.views import PromptStudioCoreView + +_VIEWS = "prompt_studio.prompt_studio_core_v2.views" +_TID = "task-abc" + + +def _view(): + view = PromptStudioCoreView() + view.get_object = MagicMock() # bypass permission/object lookup + return view + + +def _flag(enabled): + return patch(f"{_VIEWS}.check_feature_flag_status", return_value=enabled) + + +def _org(): + return patch(f"{_VIEWS}.UserSessionUtils.get_organization_id", return_value="org1") + + +def _stub_pg(pg_row=None, *, error=None): + """Patch pg_task_result read: .filter().values().first() → pg_row, or raise.""" + m = MagicMock() + if error is not None: + m.objects.filter.side_effect = error + else: + m.objects.filter.return_value.values.return_value.first.return_value = pg_row + return patch("pg_queue.models.PgTaskResult", m), m + + +def _async(ready, successful=True, result=""): + ar = MagicMock() + ar.return_value.ready.return_value = ready + ar.return_value.successful.return_value = successful + ar.return_value.result = result + return ar + + +class TestTaskStatusFlagOn: + def test_pg_completed(self): + p_pg, _ = _stub_pg({"status": "completed", "error": ""}) + with _org(), _flag(True), p_pg: + resp = _view().task_status(MagicMock(), task_id=_TID) + assert resp.data == {"task_id": _TID, "status": "completed"} + + def test_pg_failed(self): + p_pg, _ = _stub_pg({"status": "failed", "error": "extraction blew up"}) + with _org(), _flag(True), p_pg: + resp = _view().task_status(MagicMock(), task_id=_TID) + assert resp.data["status"] == "failed" + assert resp.data["error"] == "extraction blew up" + assert resp.status_code == 500 + + def test_no_pg_row_is_processing(self): + p_pg, _ = _stub_pg(None) # PG task not done yet — no "pending" row + with _org(), _flag(True), p_pg: + resp = _view().task_status(MagicMock(), task_id=_TID) + assert resp.data == {"task_id": _TID, "status": "processing"} + + def test_pg_db_error_degrades_to_processing(self): + # A transient PG read error must not 500; degrade to "processing". + p_pg, _ = _stub_pg(error=RuntimeError("db blip")) + with _org(), _flag(True), p_pg: + resp = _view().task_status(MagicMock(), task_id=_TID) + assert resp.data == {"task_id": _TID, "status": "processing"} + + +class TestTaskStatusFlagOff: + def test_celery_completed_never_queries_pg(self): + # flag off (also the Flipt-error fail-closed path): Celery + PG untouched. + p_pg, pg = _stub_pg({"status": "completed"}) # would resolve, if queried + with _org(), _flag(False), p_pg, patch(f"{_VIEWS}.AsyncResult", _async(ready=True)): + resp = _view().task_status(MagicMock(), task_id=_TID) + assert resp.data == {"task_id": _TID, "status": "completed"} + pg.objects.filter.assert_not_called() # default path never touches PG + + def test_celery_failed(self): + with _org(), _flag(False), patch( + f"{_VIEWS}.AsyncResult", _async(ready=True, successful=False, result="celery boom") + ): + resp = _view().task_status(MagicMock(), task_id=_TID) + assert resp.data["status"] == "failed" + assert "celery boom" in resp.data["error"] + assert resp.status_code == 500 + + def test_pending(self): + with _org(), _flag(False), patch(f"{_VIEWS}.AsyncResult", _async(ready=False)): + resp = _view().task_status(MagicMock(), task_id=_TID) + assert resp.data == {"task_id": _TID, "status": "processing"} diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 001d63a838..c6a978335a 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -19,6 +19,7 @@ from file_management.exceptions import FileNotFound from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin +from pg_queue.flags import PG_QUEUE_FLAG_KEY from pipeline_v2.models import Pipeline from plugins import get_plugin from rest_framework import status, viewsets @@ -81,6 +82,8 @@ from prompt_studio.prompt_studio_v2.constants import ToolStudioPromptErrors from prompt_studio.prompt_studio_v2.models import ToolStudioPrompt from prompt_studio.prompt_studio_v2.serializers import ToolStudioPromptSerializer +from unstract.core.data_models import PgTaskStatus +from unstract.flags.feature_flag import check_feature_flag_status from unstract.sdk1.utils.common import Utils as CommonUtils from .models import CustomTool @@ -873,6 +876,51 @@ def task_status( # Verify the user has access to this tool (triggers permission check) self.get_object() + # Gate on the same per-org ``pg_queue_enabled`` flag the executor dispatch + # uses (``resolve_pg_transport`` — bucketed per org): flag ON → the task rode + # PG, which records its terminal state in ``pg_task_result`` (the eager PG + # executor never writes a Celery result backend, so ``AsyncResult`` alone + # would poll "processing" forever — UN-3693); flag OFF (default) → Celery, + # unchanged, and PG is not touched. ``entity_id``/``context`` match + # ``resolve_pg_transport`` so the per-org bucket agrees with the dispatch. + # No wrapper: ``check_feature_flag_status`` already fails closed to ``False`` + # (its own try/except + warning) on any Flipt error → reads as "off" → Celery. + org_id = str(UserSessionUtils.get_organization_id(request) or "") + pg_enabled = check_feature_flag_status( + flag_key=PG_QUEUE_FLAG_KEY, + entity_id=org_id or "default", + context={"organization_id": org_id}, + ) + if pg_enabled: + from pg_queue.models import PgTaskResult + + try: + row = ( + PgTaskResult.objects.filter(task_id=task_id) + .values("status", "error") + .first() + ) + except Exception: + # A transient DB blip degrades to "processing" (a poll retries) rather + # than a bare 500 that a status-code-keyed client would misread as a + # terminal task failure. + logger.exception( + "task_status: pg_task_result read failed for %s; treating as " + "pending", + task_id, + ) + row = None + # No row → not finished yet (there is no "pending" row). Status compared + # against PgTaskStatus (the writer's source of truth), not a bare literal. + if row is None: + return Response({"task_id": task_id, "status": "processing"}) + if row["status"] == PgTaskStatus.COMPLETED.value: + return Response({"task_id": task_id, "status": "completed"}) + return Response( + {"task_id": task_id, "status": "failed", "error": row["error"] or ""}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + result = AsyncResult(task_id, app=celery_app) if not result.ready(): return Response({"task_id": task_id, "status": "processing"}) diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index 55b23ee954..b2055e1520 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -70,6 +70,15 @@ # last net) after this many extra reads past max_attempts, so a permanently # unmarkable pipeline message can't re-park forever. _DEFAULT_POISON_REPARK_BUDGET = 5 +# Retention for the pg_task_result rows the REST task_status poll reads (UN-3693). +# 24h (Celery's default result_expires) — the 1h default would expire a completed +# task's row while a late poll (a browser resumed from sleep, an API client checking +# back) is still querying it. Completed rows are status-only (no payload); FAILED rows +# carry the executor error text, which can embed document content (cf. the _forget_sql +# note in result_backend.py / UN-3683) — accepted at this horizon because that same +# text is already shown to the owner via the WebSocket event + the task_status +# response, and the row is TTL'd. +_TASK_STATUS_RETENTION_SECONDS = 86400 # Liveness: a poll loop that hasn't cycled in this many seconds is reported # unhealthy. The heartbeat is stamped at the top of each poll_once and frozen # during task execution, so this threshold doubles as an UPPER BOUND on a single @@ -434,6 +443,56 @@ def _store_reply( self._result_backend = PgResultBackend() self._result_backend.store_result(reply_key, result=result, error=error) + def _record_task_status( + self, payload: TaskPayload, *, error: str | None, executor_result: object + ) -> None: + """Record a ``dispatch_with_callback`` task's terminal status in + ``pg_task_result`` so the REST ``PromptStudio.task_status`` poll resolves + under the PG transport (UN-3693) — the eager PG executor never writes a Celery + result backend under the dispatch id, so ``AsyncResult`` alone would poll + "processing" forever. + + Keyed by the dispatch ``task_id``; ``completed`` unless the run raised + (``error`` set) or the executor reported an application failure + (``executor_result["success"]`` false). Status-only + PII-free (no payload + stored) + TTL'd. Best-effort — never raises, so it can't wedge the ack. + """ + task_id = payload.get("task_id") + if not task_id: + return + executor_failed = isinstance(executor_result, dict) and not executor_result.get( + "success", True + ) + try: + with PgResultBackend() as rb: + if error is not None or executor_failed: + msg = ( + error + or ( + executor_result.get("error") + if isinstance(executor_result, dict) + else None + ) + or "Task failed" + ) + rb.store_result( + task_id, + error=msg, + retention_seconds=_TASK_STATUS_RETENTION_SECONDS, + ) + else: + rb.store_result( + task_id, + result={}, # status-only → completed (PII-free) + retention_seconds=_TASK_STATUS_RETENTION_SECONDS, + ) + except Exception: + logger.exception( + "PG-queue consumer: could not record pg_task_result status for task " + "%s — the REST task_status poll may report 'processing'", + task_id, + ) + def _chain_continuation( self, spec: ContinuationSpec, @@ -467,6 +526,18 @@ def _chain_continuation( its user-facing WebSocket event — is lost, logged loud with the run/task/org so the stranded session is correlatable). """ + # Record the terminal status for the REST PromptStudio.task_status poll + # (UN-3693) before the enqueue, so it resolves under PG even if the callback + # enqueue below fails. This self-chain is the single terminal choke point for + # both success (``error`` None) and failure (``error`` set) of a callback + # dispatch. The recorded status reflects the EXECUTOR outcome, not callback + # delivery: on a rare on_success-enqueue-failure ``_fail_dispatch`` re-enters + # here with on_error, but ``store_result`` is first-write-wins (ON CONFLICT DO + # NOTHING) so the "completed" already written stands — a REST poll then reads + # "completed" (the executor did succeed) even though the callback's socket + # event / bookkeeping was lost. Accepted (the browser also relies on that + # socket event, so this window is REST-poll-only and rare). + self._record_task_status(payload, error=error, executor_result=prepend) try: queue = spec["queue"] kwargs = dict(spec.get("kwargs") or {}) diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index 38faa94640..f0a992c873 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -978,3 +978,80 @@ def test_reply_key_and_callback_are_mutually_exclusive(self): if __name__ == "__main__": pytest.main([__file__, "-v"]) + + +class TestRecordTaskStatus: + """UN-3693: a dispatch_with_callback task's terminal status is recorded in + pg_task_result so the REST PromptStudio.task_status poll resolves under PG. + completed unless the run raised (error) or the executor reported success=False + (completed rows are status-only; failed rows carry the executor error text). TTL'd + + best-effort — never wedges the ack.""" + + _RB = "queue_backend.pg_queue.consumer.PgResultBackend" + _RET = 86400 + + @staticmethod + def _consumer(client=None): + return PgQueueConsumer(["q"], client=client or MagicMock()) + + @staticmethod + def _entered_rb(patched): # object bound by `with PgResultBackend() as rb:` + return patched.return_value.__enter__.return_value + + def test_success_records_completed(self): + with patch(self._RB) as RB: + self._consumer()._record_task_status( + {"task_id": "tid"}, error=None, executor_result={"success": True, "data": {}} + ) + self._entered_rb(RB).store_result.assert_called_once_with( + "tid", result={}, retention_seconds=self._RET + ) + + def test_executor_reported_failure_records_failed(self): + with patch(self._RB) as RB: + self._consumer()._record_task_status( + {"task_id": "tid"}, error=None, + executor_result={"success": False, "error": "boom"}, + ) + self._entered_rb(RB).store_result.assert_called_once_with( + "tid", error="boom", retention_seconds=self._RET + ) + + def test_run_raised_records_failed(self): + with patch(self._RB) as RB: + self._consumer()._record_task_status( + {"task_id": "tid"}, error="RuntimeError: kaboom", executor_result="tid" + ) + self._entered_rb(RB).store_result.assert_called_once_with( + "tid", error="RuntimeError: kaboom", retention_seconds=self._RET + ) + + def test_no_task_id_is_noop(self): + with patch(self._RB) as RB: + self._consumer()._record_task_status( + {}, error=None, executor_result={"success": True} + ) + RB.assert_not_called() + + def test_backend_error_is_swallowed(self): + with patch(self._RB) as RB: + RB.side_effect = RuntimeError("pg down") + self._consumer()._record_task_status( + {"task_id": "tid"}, error=None, executor_result={"success": True} + ) # must not raise + + def test_chain_continuation_records_status_even_if_enqueue_fails(self): + # Single-site wiring: status is recorded before the enqueue, so a REST poll + # resolves even when the callback continuation is lost. + client = MagicMock() + client.send.side_effect = RuntimeError("enqueue down") + with patch(self._RB) as RB: + ok = self._consumer(client)._chain_continuation( + {"task_name": "cb.done", "queue": "ide_callback", "kwargs": {}}, + prepend={"success": True}, + payload={"task_id": "tid", "args": [{}]}, + ) + assert ok is False + self._entered_rb(RB).store_result.assert_called_once_with( + "tid", result={}, retention_seconds=self._RET + ) diff --git a/workers/tests/test_pg_result_backend.py b/workers/tests/test_pg_result_backend.py index c06cf34308..1f5a641506 100644 --- a/workers/tests/test_pg_result_backend.py +++ b/workers/tests/test_pg_result_backend.py @@ -66,6 +66,17 @@ def test_store_failed_round_trips(self, result_backend): assert row["error"] == "boom" assert row["result"] is None + def test_store_empty_dict_result_is_completed(self, result_backend): + # UN-3693: ide-callback status-only writes pass result={} (a non-None empty + # dict). store_result must record COMPLETED (its `result is not None` check), + # not FAILED — an `if result:` regression would flip every successful PG + # prompt to failed with the REST task_status poll reading "failed". + k = _key() + result_backend.store_result(k, result={}) + row = result_backend.get_result(k) + assert row["status"] == STATUS_COMPLETED + assert row["result"] == {} + def test_absent_returns_none(self, result_backend): assert result_backend.get_result(_key()) is None From 5a385a448415312df14928f5c9a38d1cd65fcd95 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:33:09 +0530 Subject: [PATCH 64/65] =?UTF-8?q?UN-3695=20[FIX]=20PG=20consumer=20?= =?UTF-8?q?=E2=80=94=20supervisor=20shutdown=20grace=20=3D=20VT,=20single?= =?UTF-8?q?=20shared=20join=20deadline=20(#2153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3695 [FIX] PG consumer — supervisor shutdown grace = VT, single shared join deadline The prefork supervisor SIGKILLed its own children after a hardcoded 30s on shutdown (`_SHUTDOWN_GRACE_SECONDS = 30.0`). But the chart grants the pod `terminationGracePeriodSeconds = VT + buffer` (9120s for worker-pg-file-processing, VT=9060) — so on a graceful SIGTERM (deploy / HPA pod scale-down) the supervisor was knifing an in-flight batch ~300x sooner than the pod grace it lives inside, orphaning the batch (executor finished, but nothing consumed the reply / marked COMPLETE / decremented the barrier / acked the message). - `shutdown_grace_from_env()`: the per-child drain grace now defaults to the consumer VT (`WORKER_PG_QUEUE_CONSUMER_VT_SECONDS`), floored at 30 so it can never be SHORTER than before (non-regressive; OSS/dev with the default VT stays 30, fileproc gets 9060 with no chart change). Explicit `WORKER_PG_QUEUE_CONSUMER_SHUTDOWN_GRACE_SECONDS` overrides. - `_join_children` now uses a SINGLE shared deadline instead of a per-child recomputed one. Children are SIGTERM'd together and drain in parallel, so one shared window still gives each its full grace while bounding total drain to ~grace. A per-child deadline would sum to N x grace and, at grace=VT, blow past the pod grace so k8s SIGKILLs the whole pod, hard-killing siblings that were draining cleanly. PG-worker-component only — the supervisor never runs for Celery workers, so no flag branch is needed and the Celery path is untouched. Tests: shutdown_grace_from_env (VT-tracking / floor / override) + a shared-deadline regression test (38 supervisor tests). Verified e2e with real forked children: grace tracks VT and drains a live child (no premature SIGKILL); 2 wedged children SIGKILL together at the shared deadline, not 2x. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3695 [DOC] PG queue — reference & glossary (terms + config knobs) Add a co-located README as a lookup dictionary for the PG-queue transport: config env vars (consumer / reaper / orchestration / routing) with defaults, a concepts glossary (visibility timeout, claim, redelivery, reaper, barrier, poison/re-park, prefork supervisor, shutdown grace, request-reply/callback, last_progress_at, leader election, fairness), and the table catalogue. Complements the 9e-design.md design doc. Docs only — no code change. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3695 [FIX] address review — validate grace override, fix shared-deadline false-alarm, docs Review round on the grace=VT / shared-deadline change: - shutdown_grace_from_env: reject a negative / non-finite SHUTDOWN_GRACE_SECONDS override (was silently collapsed by max(0.0, ...) -> 0 = immediate SIGKILL, or inf = shutdown hang) — fail fast like concurrency_from_env; annotate override: float|None; drop the redundant float() on the already-float default. - _wait_for_exit: poll waitpid at least once regardless of the deadline. On the single SHARED deadline a child iterated after the window elapsed was reported 'did not drain' and SIGKILLed — a false hard-kill alarm — even though it exited. - Wording: the grace is a single shared total, not per-child — corrected the docstring, constant comment, startup log ('shared across children'), and the README config row to match the _join_children docstring. - Tests: shared-deadline hardened with an advancing time.monotonic (exact 105/105/105); a 3-child all-wedged straggler case (kill+waitpid x3, one shared deadline); negative / non-finite / malformed override + malformed VT fail-fast; named helpers replace the lambda idiom; float asserts use pytest.approx (Sonar S1244). 43 tests pass; e2e re-verified. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3695 [FIX] SIGKILL warning log uses %.0fs to match the startup log (Greptile nit) The _join_children SIGKILL warning printed grace_seconds with %s (e.g. 9060.0s) while the startup log uses %.0f (9060s). Align to %.0fs. Cosmetic — no behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- workers/pg_queue_consumer/supervisor.py | 82 ++++++++-- workers/queue_backend/pg_queue/README.md | 153 +++++++++++++++++++ workers/tests/test_pg_consumer_supervisor.py | 110 +++++++++++++ 3 files changed, 334 insertions(+), 11 deletions(-) create mode 100644 workers/queue_backend/pg_queue/README.md diff --git a/workers/pg_queue_consumer/supervisor.py b/workers/pg_queue_consumer/supervisor.py index bc2a5564ed..e3553fcebd 100644 --- a/workers/pg_queue_consumer/supervisor.py +++ b/workers/pg_queue_consumer/supervisor.py @@ -36,6 +36,7 @@ import contextlib import logging +import math import multiprocessing import os import signal @@ -63,8 +64,13 @@ _MIN_HEALTHY_UPTIME_SECONDS = 10.0 # Consecutive immediate crashes after which the fleet probe is forced unhealthy. _CRASH_LOOP_THRESHOLD = 3 -# How long to wait per child for a graceful drain on shutdown before SIGKILL. -_SHUTDOWN_GRACE_SECONDS = 30.0 +# Fallback graceful-drain budget (s, shared across all children) on shutdown, used +# only when neither an explicit override nor the consumer VT is set — see +# shutdown_grace_from_env(). +# This is NOT the live value: the live grace defaults to the consumer's visibility +# timeout so a SIGTERM (deploy / HPA scale-down) lets an in-flight batch finish +# instead of a mid-flight SIGKILL that orphans it (UN-3695). +_DEFAULT_SHUTDOWN_GRACE_SECONDS = 30.0 def concurrency_from_env() -> int: @@ -93,6 +99,41 @@ def concurrency_from_env() -> int: return n +def shutdown_grace_from_env() -> float: + """Graceful-drain budget (seconds) on shutdown, shared across all children, + before SIGKILL. + + Defaults to the consumer's visibility timeout (``WORKER_PG_QUEUE_CONSUMER_VT_SECONDS``) + — the by-design upper bound on a single task's runtime — so a graceful SIGTERM + (deploy / HPA scale-down) lets an in-flight batch finish rather than being + SIGKILLed mid-flight and orphaned (UN-3695). The chart sets the pod's + ``terminationGracePeriodSeconds`` to VT + a buffer, so even a genuinely-wedged + child is SIGKILLed here just BEFORE k8s reaps the pod. An explicit + ``WORKER_PG_QUEUE_CONSUMER_SHUTDOWN_GRACE_SECONDS`` overrides (and is honoured + as-is, e.g. a short dev drain); otherwise the VT is floored at + ``_DEFAULT_SHUTDOWN_GRACE_SECONDS`` so a tiny/unset VT still gets a sane drain. + + A hardcoded 30s here previously undercut the chart's ~2.5h budget by ~300×, + SIGKILLing in-flight batches on every scale-down/rollout. + """ + from queue_backend.pg_queue.consumer import _DEFAULT_VT_SECONDS, consumer_env + + override: float | None = consumer_env("SHUTDOWN_GRACE_SECONDS", None, float) + if override is not None: + # A negative / non-finite drain budget is never intentional and would + # re-introduce the exact failure this module prevents: <0 or nan → 0 → every + # child SIGKILLed with no drain; inf → shutdown hangs until k8s hard-kills the + # pod. Fail fast at startup, mirroring concurrency_from_env()'s validation. + if not math.isfinite(override) or override < 0: + raise ValueError( + "WORKER_PG_QUEUE_CONSUMER_SHUTDOWN_GRACE_SECONDS must be a finite " + f"number >= 0, got {override!r}" + ) + return override + vt = consumer_env("VT_SECONDS", _DEFAULT_VT_SECONDS, int) + return max(_DEFAULT_SHUTDOWN_GRACE_SECONDS, float(vt)) + + class _Fleet: """Owns the per-slot child state — pid, last-fork, heartbeat, crash count and pending-restart schedule — keeping them mutually consistent. Slots are @@ -332,6 +373,12 @@ def run_supervised(concurrency: int) -> None: logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) fleet = _Fleet(concurrency) + grace_seconds = shutdown_grace_from_env() + logger.info( + "PG-queue consumer supervisor: shutdown drain grace = %.0fs (shared across " + "children)", + grace_seconds, + ) stopping = threading.Event() def _signal_children(sig: int) -> None: @@ -357,7 +404,7 @@ def _on_term(signum: int, _frame: object) -> None: if not _try_fork_child(fleet, slot): stopping.set() _signal_children(signal.SIGTERM) - _join_children(fleet, _SHUTDOWN_GRACE_SECONDS) + _join_children(fleet, grace_seconds) raise RuntimeError( f"PG-queue consumer: os.fork() failed starting child {slot}/" f"{concurrency} — reduce WORKER_PG_QUEUE_CONSUMER_CONCURRENCY or " @@ -373,7 +420,7 @@ def _on_term(signum: int, _frame: object) -> None: finally: stopping.set() _signal_children(signal.SIGTERM) - _join_children(fleet, _SHUTDOWN_GRACE_SECONDS) + _join_children(fleet, grace_seconds) if health is not None: health.stop() logger.info("PG-queue consumer supervisor: stopped") @@ -382,28 +429,41 @@ def _on_term(signum: int, _frame: object) -> None: def _wait_for_exit(pid: int, deadline: float) -> bool: """Poll ``pid`` until it exits or ``deadline`` (monotonic) passes. True if it exited (or was already reaped). + + Polls ``waitpid`` at least once regardless of the deadline: with the single + SHARED shutdown deadline, a child iterated after the window has already elapsed + would otherwise be reported "did not drain" and SIGKILLed — a false hard-kill + alarm — even though it exited cleanly within the grace. """ - while time.monotonic() < deadline: + while True: try: reaped, _status = os.waitpid(pid, os.WNOHANG) except ChildProcessError: return True if reaped != 0: return True + if time.monotonic() >= deadline: + return False time.sleep(0.1) - return False def _join_children(fleet: _Fleet, grace_seconds: float) -> None: - """Wait up to ``grace_seconds`` *per child* for a graceful drain; SIGKILL + - reap any straggler. The budget is per-child (not a single shared deadline) so - one slow-draining child can't starve the others of their grace. + """Wait up to ``grace_seconds`` TOTAL for all children to drain, then SIGKILL + + reap any straggler. A SINGLE shared deadline (not per-child): the children are + all SIGTERM'd together *before* this call and drain in parallel, so one shared + window still gives every child its full grace from that common SIGTERM — while + bounding the total wait to ~``grace_seconds``. A per-child deadline would instead + sum to N×grace and, at grace≈VT (thousands of seconds), blow past the pod's + ``terminationGracePeriodSeconds`` — so k8s SIGKILLs the whole pod, hard-killing + siblings that were still draining cleanly (UN-3695 / debate H2). """ + deadline = time.monotonic() + grace_seconds for slot, pid in fleet.alive_items(): - if _wait_for_exit(pid, time.monotonic() + grace_seconds): + if _wait_for_exit(pid, deadline): continue logger.warning( - "PG-queue consumer: child slot=%s pid=%s did not stop in %ss — SIGKILL", + "PG-queue consumer: child slot=%s pid=%s did not drain within the " + "shared %.0fs grace — SIGKILL", slot, pid, grace_seconds, diff --git a/workers/queue_backend/pg_queue/README.md b/workers/queue_backend/pg_queue/README.md new file mode 100644 index 0000000000..fa80e87fbc --- /dev/null +++ b/workers/queue_backend/pg_queue/README.md @@ -0,0 +1,153 @@ +# PG Queue — Reference & Glossary + +A quick-reference dictionary for the bespoke Postgres-backed work queue that can +stand in for Celery/RabbitMQ (epic UN-3445). For the *design* of a full-execution +migration see [`9e-design.md`](./9e-design.md); this file is the **terminology + config +lookup**. + +The transport is chosen **per org** by the Flipt flag `pg_queue_enabled` +(fail-closed to Celery), so the system runs **with and without** PG unchanged. + +--- + +## 1. Configuration — quick reference + +All knobs are environment variables. Consumer knobs are read via +`consumer_env("", …)` → `WORKER_PG_QUEUE_CONSUMER_`. Authoritative +defaults live in the code constants (`consumer.py`, `pg_queue_consumer/supervisor.py`, +`reaper.py`); cloud overrides live in the Helm chart `values.yaml`. + +**Units:** every duration is in **seconds**, and the **Default column shows the raw +value you set** — a plain number, e.g. `30` means 30 seconds (set `…=30`, not `30s`). +The `*_SECONDS` suffix names the unit; `POLL_INTERVAL` and `BACKOFF_MAX` are also +seconds (floats allowed). Counts (`CONCURRENCY`, `BATCH`, `MAX_ATTEMPTS`) are plain +integers. + +### Consumer (`WORKER_PG_QUEUE_CONSUMER_*`) +| Env suffix | One-line | Default | +|---|---|---| +| `CONCURRENCY` | Prefork consumer children per pod (1 = plain single process) | `1` | +| `VT_SECONDS` | **Visibility timeout** — how long a claimed message stays hidden before it can redeliver | `30` (chart: `9060` for file-processing ≈ 2.5h) | +| `SHUTDOWN_GRACE_SECONDS` | Graceful-drain budget (shared across all children) on SIGTERM before SIGKILL | `= VT` (floored at `30`) | +| `QUEUE` | Queue name(s) this consumer polls (comma-separated) | `default` | +| `BATCH` | Messages claimed per poll | `1` | +| `POLL_INTERVAL` | Time between polls when the queue is empty | `0.1` | +| `BACKOFF_MAX` | Max empty-queue poll backoff | `2.0` | +| `MAX_ATTEMPTS` | Max deliveries before a message is dropped as **poison** | `5` | +| `HEALTH_PORT` | Liveness HTTP port (unset → probe disabled) | unset | +| `HEALTH_STALE_SECONDS` | A poll loop idle beyond this is reported unhealthy | `60` | +| `WORKER_TYPE` | Which source worker's tasks this consumer registers (bootstrap) | — | + +### Reaper (`WORKER_PG_REAPER_*`) +| Env | One-line | Default | +|---|---|---| +| `INTERVAL_SECONDS` | Reaper cycle cadence (stuck-batch / orphan detection) | `5` | +| `SWEEP_SECONDS` | Retention-sweep cadence (expired `pg_task_result`, etc.) | `300` (5 min) | +| `HEALTH_PORT` / `HEALTH_STALE_SECONDS` | Reaper liveness probe | stale `30` | + +### Orchestration / barrier / connection +| Env | One-line | +|---|---| +| `WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS` | A barrier whose `last_progress_at` hasn't advanced this long is fast-failed by the reaper (UN-3661) | +| `WORKER_PG_ORCHESTRATOR_LEASE_SECONDS` | Leader-election lease duration for the single-orchestrator role | +| `WORKER_PG_DEDUP_RETENTION_SECONDS` | Retention for `pg_batch_dedup` rows | +| `WORKER_PG_QUEUE_CONNECT_RETRIES` / `_BACKOFF` | Reconnect attempts / backoff on a stale or broken DB connection | + +### Routing / flag +| Env / flag | One-line | +|---|---| +| `pg_queue_enabled` (Flipt, `PG_QUEUE_FLAG_KEY`) | Per-org gate that routes dispatch to PG; **fail-closed to Celery** on any Flipt error | +| `WORKER_PG_QUEUE_ENABLED_TASKS` | Comma-separated task names eligible for PG routing (empty → everything stays on Celery) | + +--- + +## 2. Concepts — glossary + +**Visibility timeout (VT)** — when a consumer *claims* a message it becomes invisible +to other consumers for `VT` seconds. If the worker finishes and deletes (acks) it, it's +gone; if the worker dies without acking, the VT expires and the message **redelivers**. +VT is the queue's only "worker died" signal (there's no live broker connection like +RabbitMQ), so recovery latency ≈ VT. + +**Claim** — an atomic `SELECT … FOR UPDATE SKIP LOCKED` that hides up to `BATCH` +ready rows for `VT` seconds and hands them to one consumer. `SKIP LOCKED` distributes +work across children and replicas without contention. + +**Redelivery / at-least-once** — a message can be delivered more than once (VT expiry +after a crash, or a poison re-park). Handlers must tolerate re-execution. + +**Near-exactly-once** — writes to an *external* system (e.g. a customer destination +DB) can't be transactional with our Postgres markers, so a crash in the tiny +write→mark gap can duplicate. Guards (FileHistory, etc.) reduce this to a narrow +window, but it isn't strictly exactly-once. + +**Poison message / re-park** — a message that fails `MAX_ATTEMPTS` times. Rather than +redeliver forever it is dropped (or re-parked with `POISON_REPARK_VT_SECONDS`, bounded +by a budget) so a permanently-bad row can't wedge the queue. + +**Prefork supervisor / fleet** — `WORKER_PG_QUEUE_CONSUMER_CONCURRENCY > 1` forks N +isolated consumer child processes (the PG analogue of Celery `--pool=prefork +--concurrency=N`). The *supervisor* owns the liveness port, re-forks crashed children +(rate-limited), and drains them on shutdown. The *fleet* is the set of children. + +**Shutdown grace** — on SIGTERM the supervisor waits up to `SHUTDOWN_GRACE_SECONDS` +(= VT) for children to finish their in-flight batch, using a **single shared +deadline**, then SIGKILLs stragglers. Must be ≤ the pod's +`terminationGracePeriodSeconds` (the chart sets `grace = VT + buffer`). + +**Liveness / heartbeat** — each child stamps its last-poll time into a shared array; +the supervisor reports the *oldest* child's staleness on `/health`. Frozen during a +long task, so a wedged child goes stale and trips the probe. + +**Reaper** — a singleton (leader-elected) sweeper that recovers **stranded** work: +fast-fails a barrier whose `last_progress_at` stalled (UN-3661), cascades a terminal +execution to its files, and sweeps expired retention rows. + +**Barrier (`PgBarrierState`)** — the fan-in counter for a batched execution: each +file-batch decrements `remaining`; when it hits 0 the aggregating callback fires. It +carries `last_progress_at` (the reaper's liveness signal for stuck detection). + +**`last_progress_at`** — timestamp bumped whenever a batch makes progress +(UN-3661). Lets the reaper fast-fail a stalled batch in ~stuck-timeout instead of the +full VT/6h horizon. + +**Leader election / orchestrator lock (`pg_orchestrator_lock`)** — a DB lease that +elects a single reaper/orchestrator across replicas; released on graceful shutdown so a +standby takes over promptly. + +**Request-reply (`reply_key`)** — a *blocking* dispatch: the caller enqueues with a +`reply_key`, the consumer stores the result under it in `pg_task_result`, and the +caller polls for it (the PG analogue of Celery's `AsyncResult`). Mutually exclusive +with the callback form. + +**Callback dispatch (`dispatch_with_callback`, `on_success` / `on_error`)** — +fire-and-forget: after the task runs, the consumer **self-chains** the success or error +continuation onto its queue via `_chain_continuation` (the PG analogue of Celery +`link` / `link_error`). No blocking caller. + +**Fairness** — a header carried on a dispatch (`org_id`, workload type, priority) so a +PG-routed run mirrors Celery's fair scheduling. + +--- + +## 3. Tables (`backend/pg_queue/models.py`) + +| Table | Purpose | +|---|---| +| `pg_queue_message` | The queue itself — one row per message; claimed via `SKIP LOCKED` + VT | +| `pg_task_result` | Request-reply results / terminal task status, keyed by reply/task id; TTL'd (`expires_at`) + reaper-swept, `ON CONFLICT DO NOTHING` | +| `pg_barrier_state` | Fan-in barrier counter for batched executions (`remaining`, `last_progress_at`) | +| `pg_batch_dedup` | Batch-level dedup guard (prevents double-dispatch of a batch) | +| `pg_orchestration_claim` | Per-execution orchestration claim (`last_progress_at` for stuck detection) | +| `pg_orchestrator_lock` | Leader-election lease for the singleton reaper/orchestrator | +| `pg_periodic_schedule` | Periodic-schedule store (the Celery-Beat replacement) | + +All PG-queue tables are **droppable side tables** that never touch +`WorkflowExecution` (UN-3533) — the PG transport can be turned off and its tables +dropped without affecting the Celery path. + +--- + +*Defaults here reflect the code constants at time of writing; the code +(`consumer.py` / `supervisor.py` / `reaper.py`) and the chart `values.yaml` are the +source of truth.* diff --git a/workers/tests/test_pg_consumer_supervisor.py b/workers/tests/test_pg_consumer_supervisor.py index c5a1912ca7..bc9145bf7a 100644 --- a/workers/tests/test_pg_consumer_supervisor.py +++ b/workers/tests/test_pg_consumer_supervisor.py @@ -16,6 +16,7 @@ from pg_queue_consumer import supervisor as sup from pg_queue_consumer.supervisor import ( _CRASH_LOOP_THRESHOLD, + _DEFAULT_SHUTDOWN_GRACE_SECONDS, _MAX_CONCURRENCY, _MIN_HEALTHY_UPTIME_SECONDS, _Fleet, @@ -26,12 +27,76 @@ _try_fork_child, _wait_for_exit, concurrency_from_env, + shutdown_grace_from_env, ) _MOD = "pg_queue_consumer.supervisor" _ENV = "WORKER_PG_QUEUE_CONSUMER_CONCURRENCY" +_VT = "WORKER_PG_QUEUE_CONSUMER_VT_SECONDS" +_GRACE = "WORKER_PG_QUEUE_CONSUMER_SHUTDOWN_GRACE_SECONDS" + + +class TestShutdownGraceFromEnv: + """UN-3695: the shutdown drain grace must track the consumer VT (not a hardcoded + 30s), so a SIGTERM drains an in-flight batch instead of SIGKILLing it mid-flight. + """ + + def test_unset_vt_and_override_defaults_to_fallback(self, monkeypatch): + monkeypatch.delenv(_VT, raising=False) + monkeypatch.delenv(_GRACE, raising=False) + assert shutdown_grace_from_env() == pytest.approx(_DEFAULT_SHUTDOWN_GRACE_SECONDS) + + def test_tracks_vt_when_set(self, monkeypatch): + # The fileproc chart sets VT=9060 → grace must follow it, not stay at 30. + monkeypatch.delenv(_GRACE, raising=False) + monkeypatch.setenv(_VT, "9060") + assert shutdown_grace_from_env() == pytest.approx(9060.0) + + def test_vt_below_fallback_is_floored(self, monkeypatch): + monkeypatch.delenv(_GRACE, raising=False) + monkeypatch.setenv(_VT, "10") + assert shutdown_grace_from_env() == pytest.approx(_DEFAULT_SHUTDOWN_GRACE_SECONDS) + + def test_explicit_override_wins_over_vt(self, monkeypatch): + monkeypatch.setenv(_VT, "9060") + monkeypatch.setenv(_GRACE, "120") + assert shutdown_grace_from_env() == pytest.approx(120.0) + + def test_override_honoured_as_is_even_below_fallback(self, monkeypatch): + # An explicit short dev drain is respected (not floored) — only the VT path floors. + monkeypatch.delenv(_VT, raising=False) + monkeypatch.setenv(_GRACE, "5") + assert shutdown_grace_from_env() == pytest.approx(5.0) + + def test_negative_override_raises(self, monkeypatch): + # A negative drain budget would collapse to 0 → SIGKILL with no drain (the + # orphan bug this module prevents). Must fail fast, not silently. + monkeypatch.setenv(_GRACE, "-5") + with pytest.raises(ValueError, match="SHUTDOWN_GRACE_SECONDS"): + shutdown_grace_from_env() + + def test_non_finite_override_raises(self, monkeypatch): + # inf would make the join deadline now+inf → shutdown hangs until k8s + # hard-kills the pod; nan collapses to 0. Reject both. + monkeypatch.setenv(_GRACE, "inf") + with pytest.raises(ValueError, match="finite"): + shutdown_grace_from_env() + + def test_malformed_override_raises(self, monkeypatch): + monkeypatch.setenv(_GRACE, "abc") + with pytest.raises(ValueError, match="SHUTDOWN_GRACE_SECONDS"): + shutdown_grace_from_env() + + def test_malformed_vt_raises(self, monkeypatch): + # Fail-fast-at-startup contract: a bad VT surfaces the offending var name. + monkeypatch.delenv(_GRACE, raising=False) + monkeypatch.setenv(_VT, "9060s") + with pytest.raises(ValueError, match="VT_SECONDS"): + shutdown_grace_from_env() + + class TestConcurrencyFromEnv: def test_unset_defaults_to_one(self, monkeypatch): monkeypatch.delenv(_ENV, raising=False) @@ -258,6 +323,51 @@ def test_straggler_is_sigkilled_and_reaped(self): kill.assert_called_once_with(111, _signal.SIGKILL) waitpid.assert_called_once_with(111, 0) + def test_shared_deadline_not_per_child(self): + # UN-3695 / debate H2: all children waited against ONE shared deadline (a + # per-child deadline serializes N wedged children to N×grace and blows past + # the pod terminationGracePeriodSeconds → siblings hard-killed). Advancing + # time.monotonic pins it: shared → the deadline is computed once (105 for + # all three); a per-child recompute would yield 105/205/305. + f = _Fleet(3) + for slot, pid in enumerate((111, 222, 333)): + f.record_fork(slot, pid) + deadlines: list[float] = [] + + def _record_drained(_pid, deadline): + deadlines.append(deadline) + return True # child exited within the window + + with ( + patch(f"{_MOD}.time.monotonic", side_effect=[100.0, 200.0, 300.0, 400.0]), + patch(f"{_MOD}._wait_for_exit", side_effect=_record_drained), + ): + _join_children(f, grace_seconds=5) + assert deadlines == pytest.approx([105.0, 105.0, 105.0]) # computed once, shared + + def test_multi_child_stragglers_share_deadline_and_all_sigkilled(self): + # 3 wedged children (none drain) → each SIGKILLed + reaped against the SAME + # shared deadline. Kill-count alone wouldn't catch a per-child regression, + # so also assert the deadline handed to every child was identical. + f = _Fleet(3) + for slot, pid in enumerate((111, 222, 333)): + f.record_fork(slot, pid) + seen: list[float] = [] + + def _record_wedged(_pid, deadline): + seen.append(deadline) + return False # never drains → forces SIGKILL + + with ( + patch(f"{_MOD}._wait_for_exit", side_effect=_record_wedged), + patch(f"{_MOD}.os.kill") as kill, + patch(f"{_MOD}.os.waitpid") as waitpid, + ): + _join_children(f, grace_seconds=5) + assert kill.call_count == 3 + assert waitpid.call_count == 3 + assert len(set(seen)) == 1 # one shared deadline across all three + class TestSupervisorHealth: def test_no_port_returns_none(self, monkeypatch): From f9b8da18ec2f62212588d07ad16bcfbb5e056825 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:34:31 +0530 Subject: [PATCH 65/65] =?UTF-8?q?UN-3695=20[FIX]=20PG=20consumer=20?= =?UTF-8?q?=E2=80=94=20renewable=20lease=20(dead=20worker=20redelivers=20i?= =?UTF-8?q?n=20minutes,=20not=20the=20full=20VT)=20(#2154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UN-3695 [FIX] PG consumer — renewable lease so a dead worker redelivers in minutes, not the full VT The claim was taken for the full visibility timeout (VT=9060s / 2.5h for file-processing), so a HARD-killed worker (OOM / node loss) stranded its in-flight message for ~2.5h before the vt expired and it redelivered. PR A fixed GRACEFUL kills (drain on SIGTERM); this covers the hard-kill path. - New LEASE_SECONDS (default 120): the message is claimed for the short lease, and a daemon thread renews it (client.set_vt) every ~lease/3 while the task runs. A live-but-slow task keeps its claim; a dead worker's renewal stops with the process so its lease expires in ~lease → redelivery in minutes. The lease is capped at vt_seconds; the renewal uses its OWN connection (the main thread owns the claim/ack connection) and is best-effort (a transient set_vt failure retries within the ~2x slack the lease/3 interval leaves before expiry; a False return = row already gone → stop). - VT_SECONDS is retained as the drain / max-runtime bound (it drives PR A's grace and the chart's health-stale / guards) — it is no longer the claim window. The claim now uses LEASE_SECONDS. - The renewal thread is started only around the task run, joined before the ack so the two never race the row, and creates its connection only on the first actual renewal (a task shorter than the interval opens no second connection). Paired chart change (unstract-cloud): sets WORKER_PG_QUEUE_CONSUMER_LEASE_SECONDS + a render guard LEASE <= VT. Tests: lease cap / renew-interval / claim-uses-lease / renew-until-stopped / stop-on-row-gone / swallow-transient-error / ctx-sets-stop-on-exit. 78 consumer tests pass. End-to-end dead-worker redelivery timing validated in the integration env. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3695 [FIX] test — fill empty with-block in lease-renewal CM test (Sonar S108) The context-manager test entered `with c._lease_renewal(42): pass` — an empty nested block (python:S108). Replace the bare pass with an observable statement (entered = True) asserted after, so the block is non-empty and still verifies the CM body runs while the renewal thread is live. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3695 [FIX] address review — renewal thread owns its connection, observable failures, batch guard Review round on the renewable-lease change (#2154): - Race: the renewal thread no longer shares self._renew_client. It owns a thread-local connection built lazily on the first renewal and closed in a finally, so a join-timeout wedge can't leave the next message's thread reusing a half-used psycopg2 connection. _make_renew_client() is the (patchable) factory. - Wedge is no longer silent: after the bounded join, a still-alive thread is logged at ERROR (connection abandoned until process exit). - set_vt -> False mid-run is a double-run signal (the ack runs only after this thread is joined, so the row can only be gone if another consumer reclaimed+acked it) — now logged, and the docstring corrected (was wrongly "acked by us"). - Narrowed the renewal except to CONN_DEAD_ERRORS (a non-connection error propagates instead of being swallowed as a "self-healing blip" forever) and added escalation: once renewal keeps failing past lease_seconds the lease is genuinely lost → ERROR. - BATCH_SIZE forced to 1 when lease < vt: renewal only covers the in-flight message, so a batch tail could lapse and double-run. All shipped workers are batch=1; this guards the knob. - Clamp is loud: lease > vt now warns (was a silent min()); comment corrected. - Iterator return annotation on the context manager; "PR C" shorthand → UN-3695. - README: effective claim window is min(LEASE, VT); lease is not a hard runtime cap (liveness probe is the backstop); batch-forced-to-1 note. - Tests: +9 (loud clamp / tiny-interval floor / non-positive reject / batch-forced / batch-kept-at-lease==vt / env wiring / renew+close / no-conn-for-sub-interval / row-gone-logs / conn-death-retry-then-escalate / non-conn-propagates / wedged-log / _handle-wraps-in-lease). 87 consumer tests pass. Deferred: lease metrics counters (ConsumerMetrics isn't wired to the consumer instance) — a separate small observability change. Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3695 [FIX] resolve SonarCloud smells — logging.exception, single-throw pytest.raises, empty block - consumer.py: escalation log uses logger.exception() (ERROR + traceback) instead of logger.error(..., exc_info=True) in the renewal except block. - tests: one throwing call per pytest.raises block; fill the empty _lease_renewal context-manager with-block (S108). Co-Authored-By: Claude Opus 4.8 (1M context) * UN-3695 [FIX] startup log shows lease (the claim window), not just vt (Greptile P2) vt_seconds is now the drain/max-runtime bound, not the claim window — the startup log showing only vt=%ss misleads an operator into thinking the claim window is 9060s when it's actually lease_seconds (e.g. 120s). Add lease=%ss. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- workers/queue_backend/pg_queue/README.md | 29 +++- workers/queue_backend/pg_queue/consumer.py | 162 +++++++++++++++++-- workers/tests/test_pg_queue_consumer.py | 175 +++++++++++++++++++++ 3 files changed, 350 insertions(+), 16 deletions(-) diff --git a/workers/queue_backend/pg_queue/README.md b/workers/queue_backend/pg_queue/README.md index fa80e87fbc..67ca71633c 100644 --- a/workers/queue_backend/pg_queue/README.md +++ b/workers/queue_backend/pg_queue/README.md @@ -27,7 +27,8 @@ integers. | Env suffix | One-line | Default | |---|---|---| | `CONCURRENCY` | Prefork consumer children per pod (1 = plain single process) | `1` | -| `VT_SECONDS` | **Visibility timeout** — how long a claimed message stays hidden before it can redeliver | `30` (chart: `9060` for file-processing ≈ 2.5h) | +| `VT_SECONDS` | The **drain / max-runtime bound** (drives `SHUTDOWN_GRACE`, `HEALTH_STALE`, the chart guards) — the *claim* window is `LEASE_SECONDS`, not this | `30` (chart: `9060` for file-processing ≈ 2.5h) | +| `LEASE_SECONDS` | **Renewable claim window** — the effective claim window is `min(LEASE, VT)`, renewed every ~that/3 while the task runs; a dead worker's claim expires in ~that → fast redelivery. With the defaults (`LEASE=120`, `VT=30`) it clamps to 30; the chart raises VT so the full 120 applies | `120` | | `SHUTDOWN_GRACE_SECONDS` | Graceful-drain budget (shared across all children) on SIGTERM before SIGKILL | `= VT` (floored at `30`) | | `QUEUE` | Queue name(s) this consumer polls (comma-separated) | `default` | | `BATCH` | Messages claimed per poll | `1` | @@ -64,14 +65,28 @@ integers. ## 2. Concepts — glossary **Visibility timeout (VT)** — when a consumer *claims* a message it becomes invisible -to other consumers for `VT` seconds. If the worker finishes and deletes (acks) it, it's -gone; if the worker dies without acking, the VT expires and the message **redelivers**. -VT is the queue's only "worker died" signal (there's no live broker connection like -RabbitMQ), so recovery latency ≈ VT. +to other consumers until its `vt` passes. If the worker finishes and deletes (acks) it, +it's gone; if the worker dies without acking, the `vt` expires and the message +**redelivers**. There's no live broker connection like RabbitMQ, so the `vt` is the +queue's only "worker died" signal — recovery latency ≈ how far out the `vt` is. + +**Renewable lease (`LEASE_SECONDS`, UN-3695)** — rather than claim for the full `VT` +(up to 2.5h), the consumer claims for a **short** `LEASE` and a background thread +**renews** it (`set_vt`) every ~`LEASE/3` while the task runs. A live-but-slow task +keeps its claim; a **dead** worker's renewal stops, so its `vt` expires in ~`LEASE` → +redelivery in **minutes, not hours**. `VT_SECONDS` is retained as the *drain / +max-runtime bound* (grace, health-stale), and the lease is clamped to it. Note the +lease is **not** a hard cap on runtime — a live-but-hung task keeps renewing forever; +the backstop for that is the liveness probe restarting the pod (process death stops +renewal). The renewal owns its own DB connection (closed on exit) and is best-effort: +a connection death retries within the `~2×` slack the `LEASE/3` interval leaves before +expiry, and escalates to an ERROR log once it keeps failing past `LEASE` (the lease is +then genuinely lost and the message may double-run). Because renewal covers only the +in-flight message, `BATCH_SIZE` is forced to 1 whenever the lease is the short window. **Claim** — an atomic `SELECT … FOR UPDATE SKIP LOCKED` that hides up to `BATCH` -ready rows for `VT` seconds and hands them to one consumer. `SKIP LOCKED` distributes -work across children and replicas without contention. +ready rows for the claim window (`min(LEASE, VT)`) and hands them to one consumer. +`SKIP LOCKED` distributes work across children and replicas without contention. **Redelivery / at-least-once** — a message can be delivered more than once (VT expiry after a crash, or a poison re-park). Handlers must tolerate re-execution. diff --git a/workers/queue_backend/pg_queue/consumer.py b/workers/queue_backend/pg_queue/consumer.py index b2055e1520..b90ca57a0e 100644 --- a/workers/queue_backend/pg_queue/consumer.py +++ b/workers/queue_backend/pg_queue/consumer.py @@ -20,12 +20,14 @@ from __future__ import annotations +import contextlib import json import logging import os import signal +import threading import time -from collections.abc import Callable +from collections.abc import Callable, Iterator from enum import Enum from typing import TYPE_CHECKING, TypeVar @@ -36,6 +38,7 @@ from ..barrier import callback_recovery_identity from ..fairness import FAIRNESS_HEADER_NAME from .client import PgQueueClient +from .connection import CONN_DEAD_ERRORS from .liveness import LivenessServer as _BaseLivenessServer from .result_backend import PgResultBackend from .task_payload import to_payload @@ -57,6 +60,15 @@ # raise it, keep vt_seconds > batch_size x worst-case task duration. _DEFAULT_BATCH = 1 _DEFAULT_VT_SECONDS = 30 +# Renewable-lease claim window (UN-3695). A claimed message is hidden for only +# this long; a background thread renews it (~every LEASE/3) while the task runs, so a +# live-but-slow task stays claimed but a DEAD worker's claim expires in ~LEASE → +# redelivery in minutes instead of the full VT. VT_SECONDS is now the drain / +# max-runtime bound (grace, health-stale), NOT the claim window. +_DEFAULT_LEASE_SECONDS = 120 +# Bounded join so a wedged renewal thread can't block the ack — it's a daemon and +# dies with the process regardless. +_LEASE_JOIN_TIMEOUT_SECONDS = 10.0 _DEFAULT_POLL_INTERVAL = 0.1 _DEFAULT_BACKOFF_MAX = 2.0 # A task claimed more than this many times keeps failing — drop it (poison) @@ -201,6 +213,7 @@ def __init__( api_client: InternalAPIClient | None = None, batch_size: int = _DEFAULT_BATCH, vt_seconds: int = _DEFAULT_VT_SECONDS, + lease_seconds: int = _DEFAULT_LEASE_SECONDS, poll_interval: float = _DEFAULT_POLL_INTERVAL, backoff_max: float = _DEFAULT_BACKOFF_MAX, max_attempts: int = _DEFAULT_MAX_ATTEMPTS, @@ -212,6 +225,7 @@ def __init__( for name, value in ( ("batch_size", batch_size), ("vt_seconds", vt_seconds), + ("lease_seconds", lease_seconds), ("poll_interval", poll_interval), ("backoff_max", backoff_max), ("max_attempts", max_attempts), @@ -245,6 +259,33 @@ def __init__( self._api_client = api_client self.batch_size = batch_size self.vt_seconds = vt_seconds + # Renewable lease (UN-3695): the claim is taken for a short LEASE and renewed + # every ~lease/3 while a task runs, so a dead worker's claim expires in ~LEASE + # (fast redelivery) but a live one is never redelivered. VT_SECONDS is the + # drain / max-runtime bound; a lease longer than it is meaningless, so clamp — + # loudly, since a lease > VT is a misconfig, not a silent no-op. + self.lease_seconds = min(lease_seconds, vt_seconds) + if lease_seconds > vt_seconds: + logger.warning( + "WORKER_PG_QUEUE_CONSUMER_LEASE_SECONDS=%s > VT_SECONDS=%s; clamped the " + "claim window to %ss (the lease can't outlast the max-runtime bound)", + lease_seconds, + vt_seconds, + self.lease_seconds, + ) + self._lease_renew_interval = max(1, self.lease_seconds // 3) + # The renewal only extends the IN-FLIGHT message; a batch tail sits claimed but + # unrenewed, so with batch>1 a slow head lets the tail's lease lapse and another + # consumer double-runs it. Force serial claims whenever the lease is the short + # (renewed) claim window; batch>1 stays available when lease == vt (no renewal). + if self.batch_size > 1 and self.lease_seconds < vt_seconds: + logger.warning( + "WORKER_PG_QUEUE_CONSUMER_BATCH_SIZE=%s forced to 1: the renewable lease " + "only covers the in-flight message, so a batch tail could lapse and " + "double-run (UN-3695)", + self.batch_size, + ) + self.batch_size = 1 self.poll_interval = poll_interval self.backoff_max = backoff_max self.max_attempts = max_attempts @@ -276,7 +317,7 @@ def poll_once(self) -> int: for queue_name in self.queue_names: try: messages = self._client.read( - queue_name, vt_seconds=self.vt_seconds, qty=self.batch_size + queue_name, vt_seconds=self.lease_seconds, qty=self.batch_size ) for message in messages: self._handle(message) @@ -289,6 +330,104 @@ def poll_once(self) -> int: ) return total + @contextlib.contextmanager + def _lease_renewal(self, msg_id: int) -> Iterator[None]: + """Keep ``msg_id``'s claim alive while a task runs (UN-3695). + + Starts a daemon thread that renews the short lease (``set_vt``) every + ``lease/3`` — so a live-but-slow task stays claimed — then signals stop and + joins on exit, before the caller acks. If the worker DIES, the thread dies + with it → the lease expires in ~``lease_seconds`` → the message redelivers in + minutes instead of the full VT. The join is bounded by + ``_LEASE_JOIN_TIMEOUT_SECONDS``; a thread still alive after that (wedged in a + stalled ``set_vt``) is logged and abandoned — it owns its own connection (see + the loop), so it can't corrupt the ack path, and a late ``set_vt`` on the + about-to-be-acked row is a benign no-op. + """ + stop = threading.Event() + thread = threading.Thread( + target=self._renew_lease_loop, + args=(msg_id, stop), + name=f"pg-lease-{msg_id}", + daemon=True, + ) + thread.start() + try: + yield + finally: + stop.set() + thread.join(timeout=_LEASE_JOIN_TIMEOUT_SECONDS) + if thread.is_alive(): + logger.error( + "PG-queue consumer: lease-renewal thread for msg_id=%s did not " + "stop within %ss — proceeding to ack; its connection is abandoned " + "until the process exits", + msg_id, + _LEASE_JOIN_TIMEOUT_SECONDS, + ) + + def _make_renew_client(self) -> PgQueueClient: + """Factory for the renewal thread's own connection (patched in tests).""" + return PgQueueClient() + + def _renew_lease_loop(self, msg_id: int, stop: threading.Event) -> None: + """Renew ``msg_id``'s claim every ``_lease_renew_interval`` until ``stop``. + + Waits *then* renews (the initial claim already set the lease), so a task + shorter than the interval never renews and opens no second connection. Owns its + connection start-to-finish (closed on exit) — never shared with the main + thread's claim/ack client. Best-effort: a connection death is retried next tick + (the interval leaves ~2x slack before expiry), but if it keeps failing past + ``lease_seconds`` the lease is genuinely lost (the row can be reclaimed) and it + escalates to ERROR. A ``False`` return means the row was already deleted — and + since the ack runs only after this thread is joined, that means another consumer + reclaimed + acked it, i.e. this task is double-running: logged, then stop. A + non-connection error is left to propagate (fail loud, not swallowed forever). + """ + client: PgQueueClient | None = None + last_ok = time.monotonic() + try: + while not stop.wait(self._lease_renew_interval): + try: + # Built on the FIRST renewal (not on thread start), so a task + # shorter than the interval opens no second connection. + if client is None: + client = self._make_renew_client() + if not client.set_vt(msg_id, self.lease_seconds): + logger.warning( + "PG-queue consumer: lease for msg_id=%s lost (row already " + "gone) — it was reclaimed and this task may double-run", + msg_id, + ) + return + last_ok = time.monotonic() + except CONN_DEAD_ERRORS: + down_for = time.monotonic() - last_ok + if down_for >= self.lease_seconds: + logger.exception( + "PG-queue consumer: lease renewal for msg_id=%s failing for " + "%.0fs (>= lease %ss) — the lease has likely expired and " + "this task may double-run", + msg_id, + down_for, + self.lease_seconds, + ) + else: + logger.warning( + "PG-queue consumer: lease renewal for msg_id=%s failed " + "(retry in %ss; %.0fs of %ss slack used) — a dead " + "connection self-heals on the next tick", + msg_id, + self._lease_renew_interval, + down_for, + self.lease_seconds, + exc_info=True, + ) + finally: + if client is not None: + with contextlib.suppress(Exception): + client.close() + def _handle(self, message: QueueMessage) -> None: payload = message.message task_name = payload.get("task_name") @@ -343,12 +482,15 @@ def _handle(self, message: QueueMessage) -> None: # header so a PG-routed run mirrors the Celery dispatch path. fairness = payload.get("fairness") headers = {FAIRNESS_HEADER_NAME: fairness} if fairness else None - eager = task.apply( - args=payload.get("args") or [], - kwargs=payload.get("kwargs") or {}, - headers=headers, - throw=True, - ) + # Renew the short lease while the (possibly long) task runs, so a dead + # worker's claim expires fast but a live one is never redelivered (UN-3695). + with self._lease_renewal(message.msg_id): + eager = task.apply( + args=payload.get("args") or [], + kwargs=payload.get("kwargs") or {}, + headers=headers, + throw=True, + ) except Exception as exc: if reply_key or on_success or on_error: # Request-reply / async-callback dispatch: surface the failure on @@ -826,10 +968,11 @@ def run(self, *, install_signals: bool = True, require_tasks: bool = True) -> No name for name in self._app.tasks if not name.startswith("celery.") ) logger.info( - "PG-queue consumer started (queues=%r, batch=%s, vt=%ss) — " + "PG-queue consumer started (queues=%r, batch=%s, lease=%ss, vt=%ss) — " "%d application task(s) registered: %s", self.queue_names, self.batch_size, + self.lease_seconds, self.vt_seconds, len(app_tasks), ", ".join(app_tasks) or "(none)", @@ -917,6 +1060,7 @@ def build_consumer_from_env() -> PgQueueConsumer: queue_names=consumer_env("QUEUE", [_DEFAULT_QUEUE], _parse_queue_list), batch_size=consumer_env("BATCH", _DEFAULT_BATCH, int), vt_seconds=consumer_env("VT_SECONDS", _DEFAULT_VT_SECONDS, int), + lease_seconds=consumer_env("LEASE_SECONDS", _DEFAULT_LEASE_SECONDS, int), poll_interval=consumer_env("POLL_INTERVAL", _DEFAULT_POLL_INTERVAL, float), backoff_max=consumer_env("BACKOFF_MAX", _DEFAULT_BACKOFF_MAX, float), max_attempts=consumer_env("MAX_ATTEMPTS", _DEFAULT_MAX_ATTEMPTS, int), diff --git a/workers/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py index f0a992c873..24b505d8f4 100644 --- a/workers/tests/test_pg_queue_consumer.py +++ b/workers/tests/test_pg_queue_consumer.py @@ -10,6 +10,8 @@ import logging import os +import threading +import time from unittest.mock import MagicMock, patch import pytest @@ -17,6 +19,7 @@ from queue_backend.fairness import FAIRNESS_HEADER_NAME from queue_backend.pg_queue import to_payload from queue_backend.pg_queue.client import QueueMessage +from queue_backend.pg_queue.connection import CONN_DEAD_ERRORS from queue_backend.pg_queue.consumer import PgQueueConsumer # Registered test tasks (namespaced). apply() runs their bodies in-process. @@ -1055,3 +1058,175 @@ def test_chain_continuation_records_status_even_if_enqueue_fails(self): self._entered_rb(RB).store_result.assert_called_once_with( "tid", result={}, retention_seconds=self._RET ) + + +class TestLeaseRenewal: + """UN-3695: the claim is taken for the short lease and renewed while the task + runs, so a dead worker's claim expires in ~lease (fast redelivery) instead of the + full VT. VT_SECONDS stays the drain/max-runtime bound. + """ + + # --- construction / config --- + + def test_lease_clamped_to_vt_loudly(self, caplog): + with caplog.at_level(logging.WARNING, logger="queue_backend.pg_queue.consumer"): + c = PgQueueConsumer( + ["q"], client=MagicMock(), vt_seconds=100, lease_seconds=200 + ) + assert c.lease_seconds == 100 # clamped — the lease can't outlast VT + assert "clamped the claim window" in caplog.text # loud, not silent + + def test_lease_and_renew_interval(self): + c = PgQueueConsumer(["q"], client=MagicMock(), vt_seconds=9060, lease_seconds=120) + assert c.lease_seconds == 120 + assert c._lease_renew_interval == 40 # lease // 3 → 2 attempts of slack + + def test_tiny_lease_renew_interval_floored_at_1(self): + c = PgQueueConsumer(["q"], client=MagicMock(), vt_seconds=2, lease_seconds=2) + assert c._lease_renew_interval == 1 # max(1, 2 // 3) + + def test_non_positive_lease_rejected(self): + client = MagicMock() + with pytest.raises(ValueError, match="lease_seconds must be positive"): + PgQueueConsumer(["q"], client=client, lease_seconds=0) + + def test_batch_forced_to_1_when_lease_shorter_than_vt(self, caplog): + with caplog.at_level(logging.WARNING, logger="queue_backend.pg_queue.consumer"): + c = PgQueueConsumer( + ["q"], client=MagicMock(), vt_seconds=9060, lease_seconds=120, batch_size=8 + ) + assert c.batch_size == 1 # renewal only covers the in-flight message + assert "forced to 1" in caplog.text + + def test_batch_kept_when_lease_equals_vt(self): + # lease == vt → no short renewal window → batch>1 is safe, left untouched. + c = PgQueueConsumer( + ["q"], client=MagicMock(), vt_seconds=50, lease_seconds=50, batch_size=4 + ) + assert c.lease_seconds == 50 and c.batch_size == 4 + + def test_env_wires_lease_seconds(self, monkeypatch): + from queue_backend.pg_queue import consumer as mod + + monkeypatch.setenv("WORKER_PG_QUEUE_CONSUMER_LEASE_SECONDS", "77") + monkeypatch.setenv("WORKER_PG_QUEUE_CONSUMER_VT_SECONDS", "9060") + with patch.object(mod, "PgQueueClient"): # no real DB connection + c = mod.build_consumer_from_env() + assert c.lease_seconds == 77 + + def test_poll_claims_with_lease_not_vt(self): + client = MagicMock() + client.read.return_value = [] + c = PgQueueConsumer(["q"], client=client, vt_seconds=9060, lease_seconds=120) + c.poll_once() + client.read.assert_called_with("q", vt_seconds=120, qty=c.batch_size) + + # --- the renewal loop (own connection via the _make_renew_client factory) --- + + def _renewing(self, renew_client, lease_seconds=3): + c = PgQueueConsumer(["q"], client=MagicMock(), lease_seconds=lease_seconds) + c._make_renew_client = MagicMock(return_value=renew_client) + return c + + def test_renews_until_stopped_then_closes_own_client(self): + rc = MagicMock() + rc.set_vt.return_value = True + c = self._renewing(rc) + stop = MagicMock() + stop.wait.side_effect = [False, False, True] # renew twice, then stop + c._renew_lease_loop(99, stop) + assert rc.set_vt.call_count == 2 + rc.set_vt.assert_called_with(99, 3) # renews with the lease + stop.wait.assert_called_with(c._lease_renew_interval) + rc.close.assert_called_once() # own connection released on exit + + def test_no_client_built_for_sub_interval_task(self): + c = self._renewing(MagicMock()) + stop = MagicMock() + stop.wait.side_effect = [True] # stopped before the first renewal tick + c._renew_lease_loop(99, stop) + c._make_renew_client.assert_not_called() # short task opens no 2nd connection + + def test_row_gone_logs_double_run_and_stops(self, caplog): + rc = MagicMock() + rc.set_vt.return_value = False # reclaimed + acked elsewhere (we ack later) + c = self._renewing(rc) + stop = MagicMock() + stop.wait.side_effect = [False, False, True] + with caplog.at_level(logging.WARNING, logger="queue_backend.pg_queue.consumer"): + c._renew_lease_loop(99, stop) + assert rc.set_vt.call_count == 1 # stopped after the first gone + assert "may double-run" in caplog.text # the lost-lease signal is logged + rc.close.assert_called_once() + + def test_conn_death_retried_then_escalates_past_lease(self, caplog): + rc = MagicMock() + rc.set_vt.side_effect = CONN_DEAD_ERRORS[0]("db down") + c = self._renewing(rc, lease_seconds=3) + stop = MagicMock() + stop.wait.side_effect = [False, False, True] + # last_ok=0; 1st failure at t=0 (< lease → WARNING); 2nd at t=10 (≥ lease → ERROR) + with patch( + "queue_backend.pg_queue.consumer.time.monotonic", + side_effect=[0.0, 0.0, 10.0], + ): + with caplog.at_level( + logging.WARNING, logger="queue_backend.pg_queue.consumer" + ): + c._renew_lease_loop(99, stop) # must NOT raise + assert rc.set_vt.call_count == 2 + assert "self-heals" in caplog.text # within-slack retry + assert "may double-run" in caplog.text # escalated once past the lease + + def test_non_connection_error_propagates(self): + # A programming bug is NOT swallowed as a "self-healing blip" forever. + rc = MagicMock() + rc.set_vt.side_effect = AttributeError("bug") + c = self._renewing(rc) + stop = MagicMock() + stop.wait.side_effect = [False, False, True] + with pytest.raises(AttributeError): + c._renew_lease_loop(99, stop) + rc.close.assert_called_once() # still released via finally + + # --- the context manager + _handle wiring --- + + def test_ctx_runs_loop_and_sets_stop_on_exit(self): + c = PgQueueConsumer(["q"], client=MagicMock(), lease_seconds=3) + entered = False + with patch.object(c, "_renew_lease_loop") as loop: + with c._lease_renewal(42): + entered = True # CM body runs while the renewal thread is live + assert entered + loop.assert_called_once() + msg_id, stop = loop.call_args.args + assert msg_id == 42 + assert isinstance(stop, threading.Event) and stop.is_set() # stopped on exit + + def test_ctx_logs_when_thread_wedged_past_join_timeout(self, caplog): + c = PgQueueConsumer(["q"], client=MagicMock(), lease_seconds=3) + entered = False + # A loop that ignores stop → join times out → the thread is still alive. + with patch.object(c, "_renew_lease_loop", lambda mid, s: time.sleep(0.3)): + with patch( + "queue_backend.pg_queue.consumer._LEASE_JOIN_TIMEOUT_SECONDS", 0.01 + ): + with caplog.at_level( + logging.ERROR, logger="queue_backend.pg_queue.consumer" + ): + with c._lease_renewal(7): + entered = True # CM body runs even though the thread wedges + assert entered + assert "did not stop within" in caplog.text # wedged thread is not silent + + def test_handle_wraps_task_run_in_lease_renewal(self): + # Integration: a real run goes through _lease_renewal (dropping the `with` + # in _handle would keep the unit tests green — this guards that regression). + client = MagicMock() + client.read.return_value = [_msg(1, _ok_payload(3, 4))] + c = PgQueueConsumer(["q"], client=client) + with patch.object(c, "_lease_renewal", wraps=c._lease_renewal) as lease: + c.poll_once() + lease.assert_called_once_with(1) # the in-flight msg_id was leased + assert _calls == [(3, 4)] # task still ran + client.delete.assert_called_once_with(1) # and acked