diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index 970b5fac6a..d12374f44e 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -340,6 +340,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/executor_rpc.py b/backend/pg_queue/executor_rpc.py new file mode 100644 index 0000000000..cd850a2440 --- /dev/null +++ b/backend/pg_queue/executor_rpc.py @@ -0,0 +1,113 @@ +"""Executor-RPC for the PG path — backend (Django) transport adapter. + +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 :func:`get_executor_dispatcher` +factory that wires them together. + +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.db import close_old_connections + +from pg_queue.models import PgTaskResult +from pg_queue.producer import enqueue_task +from unstract.core.polling import poll_for_row +from unstract.sdk1.execution.dispatcher import ExecutionDispatcher +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 + +# 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). + + The single ``pg_queue_enabled`` Flipt flag (fail-closed). + """ + return resolve_pg_transport(context) + + +class DjangoQueueTransport(QueueTransport): + """:class:`QueueTransport` over the Django ORM (the backend half). + + Inherits the Protocol so a type-checker verifies this implementation against the + seam independently of the ``PgExecutionDispatcher(...)`` construction site. + """ + + 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: + enqueue_task( + task_name=EXECUTE_TASK, + queue=queue, + args=[context.to_dict()], + org_id=org_id, + reply_key=reply_key, + on_success=on_success, + on_error=on_error, + task_id=task_id, + ) + + def wait_for_result(self, reply_key: str, timeout: float) -> ExecResultRow | None: + """Poll ``pg_task_result`` until the row appears or *timeout* elapses. + + 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). + """ + + def _fetch() -> ExecResultRow | None: + row = PgTaskResult.objects.filter(pk=reply_key).first() + if row is 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) + + +def get_executor_dispatcher( + celery_app: object | None = None, +) -> RoutingExecutionDispatcher: + """Factory: the gate-routed executor dispatcher (PG when enabled, else Celery).""" + return RoutingExecutionDispatcher( + celery=ExecutionDispatcher(celery_app=celery_app), + pg=PgExecutionDispatcher(DjangoQueueTransport()), + resolve=resolve_executor_transport, + ) diff --git a/backend/pg_queue/flags.py b/backend/pg_queue/flags.py new file mode 100644 index 0000000000..cf190134c1 --- /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. 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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..2146f3868e --- /dev/null +++ b/backend/pg_queue/models.py @@ -0,0 +1,411 @@ +from django.db import models +from django.db.models import F +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() + # 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 — + # 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) + # 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( + F("queue_name"), + F("priority").desc(), + F("msg_id"), + name="pg_queue_message_dequeue_idx", + ) + ] + + +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 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``). + + 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. 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``. + """ + + 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. + 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) + # 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" + 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"), + ] + + +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. + + 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``. + """ + + # 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) + # 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) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "pg_periodic_schedule" + indexes = [ + # 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=["pg_owned", "enabled", "next_run_at"], + 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"), + ] + + +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 + 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, 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 + 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. 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) + # 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: + db_table = "pg_orchestration_claim" diff --git a/backend/pg_queue/producer.py b/backend/pg_queue/producer.py new file mode 100644 index 0000000000..1056d44476 --- /dev/null +++ b/backend/pg_queue/producer.py @@ -0,0 +1,145 @@ +"""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, + ContinuationSpec, + 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, + 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``. + + 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). + + ``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 " + 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, + } + # 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. + 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_executor_rpc.py b/backend/pg_queue/tests/test_executor_rpc.py new file mode 100644 index 0000000000..610c45ee0b --- /dev/null +++ b/backend/pg_queue/tests/test_executor_rpc.py @@ -0,0 +1,116 @@ +"""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 resolver's delegation to the shared +single-Flipt-flag gate, and the factory wiring. +""" + +from unittest.mock import MagicMock, patch + +from unstract.workflow_execution.executor_rpc import ExecResultRow + +from pg_queue.executor_rpc import ( + DjangoQueueTransport, + RoutingExecutionDispatcher, + get_executor_dispatcher, + resolve_executor_transport, +) + +_MOD = "pg_queue.executor_rpc" + + +def _ctx() -> MagicMock: + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "r" + c.to_dict.return_value = {"run_id": "r"} + return c + + +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"), + ): + # 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}.PgTaskResult", MagicMock(objects=qs)), + patch(f"{_MOD}.close_old_connections") as cl, + patch("unstract.core.polling.time.sleep") as slp, + ): + 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 + + +class TestResolveExecutorTransport: + 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 + 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_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 Flipt resolver. + assert isinstance(d._pg._transport, DjangoQueueTransport) + assert d._resolve is resolve_executor_transport diff --git a/backend/pg_queue/tests/test_producer.py b/backend/pg_queue/tests/test_producer.py new file mode 100644 index 0000000000..c09466f2a6 --- /dev/null +++ b/backend/pg_queue/tests/test_producer.py @@ -0,0 +1,143 @@ +"""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") + + 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/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/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 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 91515db118..2a72e945ee 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 @@ -74,7 +74,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 @@ -293,9 +292,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/helper.py b/backend/scheduler/helper.py index ad265ca45e..d4ea878c42 100644 --- a/backend/scheduler/helper.py +++ b/backend/scheduler/helper.py @@ -9,12 +9,14 @@ 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, delete_periodic_task, disable_task, enable_task, + mirror_periodic_schedule_upsert, ) logger = logging.getLogger(__name__) @@ -61,6 +63,25 @@ 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, + ) + # 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..039b583c84 --- /dev/null +++ b/backend/scheduler/ownership.py @@ -0,0 +1,140 @@ +"""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 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 + +import logging +import os + +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__) + +# 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``: 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. + """ + if os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": + logger.warning( + "resolve_schedule_owner: 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=PG_QUEUE_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 ad8c943069..86cf8a7a16 100644 --- a/backend/scheduler/tasks.py +++ b/backend/scheduler/tasks.py @@ -4,7 +4,10 @@ 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 from pipeline_v2.models import Pipeline from pipeline_v2.pipeline_processor import PipelineProcessor from utils.user_context import UserContext @@ -13,6 +16,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 +129,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 +228,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,11 +243,29 @@ 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) 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/__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..ea56ccea5b --- /dev/null +++ b/backend/scheduler/tests/test_pg_periodic_schedule_mirror.py @@ -0,0 +1,230 @@ +"""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 contextlib import nullcontext +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, + patch("scheduler.helper.reconcile_ownership_for") as reconcile, + ): + 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 + # ②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: + 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 + + @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() + 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) + + # 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 + 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 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..61f16bc4ef --- /dev/null +++ b/backend/scheduler/tests/test_pg_schedule_ownership.py @@ -0,0 +1,201 @@ +"""Unit tests for the schedule-ownership ramp control (Phase 9, ②c). + +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 +from unittest.mock import MagicMock, patch + +import pytest + +from scheduler import ownership + +_PID = "11111111-1111-1111-1111-111111111111" +_ORG = "org_abc" + + +class TestResolveScheduleOwner: + def test_flipt_unavailable_is_beat(self, monkeypatch): + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "false") + 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 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 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 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() diff --git a/backend/uv.lock b/backend/uv.lock index bad2b30614..c70acebb2a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -4024,6 +4024,7 @@ dependencies = [ { name = "unstract-core" }, { name = "unstract-filesystem" }, { name = "unstract-flags" }, + { name = "unstract-sdk1" }, { name = "unstract-tool-registry" }, { name = "unstract-tool-sandbox" }, ] @@ -4033,6 +4034,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/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/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_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/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 diff --git a/backend/workflow_manager/internal_api_views.py b/backend/workflow_manager/internal_api_views.py index 931ba94138..bdc09a0a50 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,23 @@ 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. 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( { "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/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/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/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 = [] diff --git a/backend/workflow_manager/workflow_v2/models/execution.py b/backend/workflow_manager/workflow_v2/models/execution.py index e59e8faebb..a8bc120540 100644 --- a/backend/workflow_manager/workflow_v2/models/execution.py +++ b/backend/workflow_manager/workflow_v2/models/execution.py @@ -144,6 +144,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/__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_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/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/tests/test_transport.py b/backend/workflow_manager/workflow_v2/tests/test_transport.py new file mode 100644 index 0000000000..a93c926f4a --- /dev/null +++ b/backend/workflow_manager/workflow_v2/tests/test_transport.py @@ -0,0 +1,161 @@ +"""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 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 unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, + WorkflowTransport, + normalize_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: + 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: + @pytest.fixture(autouse=True) + def _flipt_available(self, monkeypatch): + """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. + """ + monkeypatch.setenv("FLIPT_SERVICE_AVAILABLE", "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() + + 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() + + 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 + + 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 + + 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 + + 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", + }, + ) + + 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) + + 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"} + + def test_result_is_a_valid_transport_value(self): + valid = {t.value for t in WorkflowTransport} + with patch(_FLIPT, return_value=True): + assert ( + resolve_transport(execution_id="e1", organization_id="org1") 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..08b2f31c24 --- /dev/null +++ b/backend/workflow_manager/workflow_v2/transport.py @@ -0,0 +1,145 @@ +"""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. + +The transport is resolved from a single Flipt evaluation: + + Flipt boolean (``pg_queue_enabled``) → transport + +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. + +- **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 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 + +import logging +import os +from typing import TYPE_CHECKING + +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 + +if TYPE_CHECKING: + from uuid import UUID + +logger = logging.getLogger(__name__) + +# 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( + *, + 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: + 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 + + # 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 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: 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 abe20703e1..0e2e28aacd 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 @@ -57,15 +59,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", } @@ -465,6 +472,101 @@ 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 + + @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, @@ -497,53 +599,88 @@ 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() } org_schema = UserContext.get_organization_identifier() log_events_id = StateStore.get(Common.LOG_EVENTS_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, - }, - queue=queue, + # 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. 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. 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, ) - logger.info( - f"[{org_schema}] Job '{async_execution}' has been enqueued for " - f"execution_id '{execution_id}', '{len(hash_values_of_files)}' files" + 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. + dispatch_handle = cls._dispatch_orchestrator_task( + transport=transport, + queue=queue, + args=dispatch_args, + kwargs=dispatch_kwargs, + org_schema=org_schema or "", ) + # 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: - logger.warning( - f"[{org_schema}] Celery returned empty task_id for 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 + # 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 '{async_execution.id}' has been enqueued for " - f"execution_id '{execution_id}', '{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 @@ -557,36 +694,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/docker/docker-compose.yaml b/docker/docker-compose.yaml index 5884b88994..019c819ad6 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -562,6 +562,337 @@ 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 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). + 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 + # 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: + - ./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 + # 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: + - ./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 + # 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: + - ./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 + # 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: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + profiles: + - pg-queue + + # 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 + # 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: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + 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} + # 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 + volumes: + - ./workflow_data:/data + - ${TOOL_REGISTRY_CONFIG_SRC_PATH}:/data/tool_registry_config + - prompt_studio_data:/app/prompt-studio-data + 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 + # (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 + 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 + # 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: + - ./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..e07444096e 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 single `pg_queue_enabled` Flipt flag +# (default off, fail-closed) decides per-execution transport, and stays off until +# the rollout ramp. 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/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..8b4286239b --- /dev/null +++ b/pg_benchmark/README.md @@ -0,0 +1,106 @@ +# 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. + +### 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 +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..6fb18231de --- /dev/null +++ b/pg_benchmark/pg_benchmark/cli.py @@ -0,0 +1,202 @@ +"""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 _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: + 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 + 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, + api_key=api_key, + 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} + + 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( + "--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) + 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/mock_server.py b/pg_benchmark/pg_benchmark/mock_server.py new file mode 100644 index 0000000000..9b0936823c --- /dev/null +++ b/pg_benchmark/pg_benchmark/mock_server.py @@ -0,0 +1,229 @@ +"""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 + + +# 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).""" + + 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() + # 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(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(MOCK_MODEL, count, config.embedding_dim)) + elif path.endswith("/completions"): + counter.bump("chat") + self._send_json(text_completion(MOCK_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/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..fc9f320913 --- /dev/null +++ b/pg_benchmark/pg_benchmark/trigger.py @@ -0,0 +1,150 @@ +"""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 +from urllib.parse import parse_qs, urlparse + +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) # multipart form fields + extra_headers: dict[str, str] = field(default_factory=dict) # e.g. X-subscription-id + + @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 _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 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 + 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 + + +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}", **cfg.extra_headers} + 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..4d87054022 --- /dev/null +++ b/pg_benchmark/tests/test_load.py @@ -0,0 +1,218 @@ +"""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 math + + +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 + + +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.""" + + 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="https://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 _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( + 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 _close(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_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 diff --git a/pg_benchmark/tests/test_stats.py b/pg_benchmark/tests/test_stats.py new file mode 100644 index 0000000000..0fcfa778d5 --- /dev/null +++ b/pg_benchmark/tests/test_stats.py @@ -0,0 +1,120 @@ +"""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 + + +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 _close(percentile(values, 0), 1.0) + assert _close(percentile(values, 100), 5.0) + + def test_median_of_odd_sample(self): + assert _close(percentile([3.0, 1.0, 2.0], 50), 2.0) + + def test_median_of_even_sample_interpolates(self): + 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 _close(percentile(values, 95), 95.0) + + def test_single_element(self): + 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): + percentile([], 50) + + +class TestSummarize: + def test_empty_sample_is_all_zero(self): + s = summarize([]) + assert s.empty + 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 _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 + assert _close(s.stdev, 0.0) and _close(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 _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 _close(e.parallelism, 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 _close(s.stdev, expected) diff --git a/pg_benchmark/tests/test_trigger.py b/pg_benchmark/tests/test_trigger.py new file mode 100644 index 0000000000..d919d1fde5 --- /dev/null +++ b/pg_benchmark/tests/test_trigger.py @@ -0,0 +1,58 @@ +"""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 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") == { + "tags": "ali1", + "timeout": "300", + } + + def test_missing_separator_raises(self): + import pytest + + with pytest.raises(SystemExit): + _parse_kv(["bad"], sep=":", label="--header") 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/unstract/core/src/unstract/core/data_models.py b/unstract/core/src/unstract/core/data_models.py index b56d0487ff..91329e2371 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, NotRequired, TypedDict logger = logging.getLogger(__name__) @@ -200,6 +200,184 @@ 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 + + +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 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 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``. + + 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``). + + ``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 + args: list[Any] + kwargs: dict[str, Any] + 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): + """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: """Result of listing files from a source.""" @@ -375,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/unstract/core/src/unstract/core/execution_dispatch.py b/unstract/core/src/unstract/core/execution_dispatch.py new file mode 100644 index 0000000000..133f85a8dd --- /dev/null +++ b/unstract/core/src/unstract/core/execution_dispatch.py @@ -0,0 +1,93 @@ +"""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, 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. + + ``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: 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, + 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/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/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/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..14dcc6e041 --- /dev/null +++ b/unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py @@ -0,0 +1,461 @@ +"""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: 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. + +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, + *, + flag_key: str = PG_QUEUE_FLAG_KEY, +) -> bool: + """True → route this executor dispatch over PG; False → Celery (default). + + 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 os.environ.get("FLIPT_SERVICE_AVAILABLE", "false").lower() != "true": + logger.warning( + "resolve_pg_transport: 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/uv.lock b/uv.lock index b4a38a31e0..aa17b0c687 100644 --- a/uv.lock +++ b/uv.lock @@ -617,6 +617,18 @@ wheels = [ { 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" }, ] +[[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.1" @@ -3912,9 +3924,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" }, @@ -3935,9 +3949,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" }, @@ -3976,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" }, ] @@ -3987,6 +4004,7 @@ dependencies = [ { name = "unstract-core" }, { name = "unstract-filesystem" }, { name = "unstract-flags" }, + { name = "unstract-sdk1" }, { name = "unstract-tool-registry" }, { name = "unstract-tool-sandbox" }, ] @@ -3996,6 +4014,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/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/api-deployment/tasks.py b/workers/api-deployment/tasks.py index f4bec94a73..a07ef7b7bd 100644 --- a/workers/api-deployment/tasks.py +++ b/workers/api-deployment/tasks.py @@ -26,7 +26,14 @@ 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, + is_pg_transport, + normalize_transport, +) from unstract.core.worker_models import ApiDeploymentResultStatus logger = WorkerLogger.get_logger(__name__) @@ -415,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 @@ -683,6 +699,7 @@ def _run_workflow_api( org_id=str(schema_name), workload_type=WorkloadType.API, ) + # 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", @@ -694,6 +711,7 @@ def _run_workflow_api( callback_queue=file_processing_callback_queue, app_instance=app, fairness=api_fairness, + transport=transport, ) if result is None: @@ -791,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/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/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/file_processing/tasks.py b/workers/file_processing/tasks.py index 3aa93296e7..05b71ffaa4 100644 --- a/workers/file_processing/tasks.py +++ b/workers/file_processing/tasks.py @@ -11,6 +11,11 @@ from typing import Any from queue_backend import worker_task +from queue_backend.barrier import BarrierContext +from queue_backend.pg_barrier import ( + SKIPPED_TERMINAL_EXECUTION_KEY, + run_batch_with_barrier, +) # Import shared worker infrastructure from shared.api import InternalAPIClient @@ -49,6 +54,7 @@ FileHashData, PreCreatedFileData, WorkerFileData, + WorkflowTransport, ) from unstract.core.worker_models import ( ApiDeploymentResultStatus, @@ -222,30 +228,125 @@ def _enhance_batch_with_mrq_flags( ) -def _process_file_batch_core( - task_instance, file_batch_data: dict[str, Any] -) -> dict[str, Any]: - """Core implementation of file batch processing. +class _TerminalExecutionSkip(Exception): + """A batch was delivered for an execution already in a terminal state. - This function contains the actual processing logic that both the new task - and Django compatibility task will use. + 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. - Args: - task_instance: The Celery task instance (self) - file_batch_data: Dictionary that will be converted to FileBatchData dataclass + 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. - Returns: - Dictionary with successful_files and failed_files counts + 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. """ - celery_task_id = ( - task_instance.request.id if hasattr(task_instance, "request") else "unknown" - ) + 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 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. + + 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( + 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, *, is_pg: bool +) -> dict[str, Any]: + """The actual batch work (validate → setup → pre-create → process → compile). + + 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. + + ``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 - context = _setup_execution_context(batch_data, celery_task_id) + # 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: + 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." + ) + return _terminal_skip_result(batch_data) # Step 3: Handle manual review logic # context = _handle_manual_review_logic(context) @@ -260,6 +361,47 @@ 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. + # 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 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), + ) + + @worker_task( bind=True, name=TaskName.PROCESS_FILE_BATCH, @@ -272,18 +414,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: @@ -320,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 + 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 @@ -372,6 +527,11 @@ def _setup_execution_context( execution_context = execution_response.data workflow_execution = execution_context.get("execution", {}) + # 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) # This enables the WorkerWorkflowLogger to send logs to the UI via WebSocket execution_log_id = workflow_execution.get("execution_log_id") @@ -439,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 @@ -710,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 @@ -1479,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. @@ -1492,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 @@ -1507,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/general/tasks.py b/workers/general/tasks.py index 38d363173b..7200a65892 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 @@ -52,10 +56,13 @@ # Import shared data models for type safety from unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, ExecutionStatus, FileBatchData, FileHashData, WorkerFileData, + is_pg_transport, + normalize_transport, ) # Import common workflow utilities @@ -135,6 +142,55 @@ def _log_batch_creation_statistics( ) +def _should_skip_duplicate_orchestration( + 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 + 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, organization_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, @@ -157,6 +213,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. @@ -191,7 +248,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, schema_name, 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 @@ -228,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 @@ -268,6 +369,7 @@ def async_execute_bin_general( use_file_history, scheduled, schema_name, + transport=transport, **kwargs, ) @@ -315,6 +417,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: @@ -486,6 +605,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. @@ -508,6 +628,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: @@ -546,6 +672,7 @@ def _execute_general_workflow( "execution_mode": execution_mode, }, is_scheduled=scheduled, + transport=transport, ) logger.info( @@ -729,6 +856,7 @@ def _execute_general_workflow( execution_mode=execution_mode, use_file_history=use_file_history, organization_id=api_client.organization_id, + transport=transport, **kwargs, ) @@ -740,14 +868,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, @@ -783,6 +925,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. @@ -976,6 +1119,7 @@ def _orchestrate_file_processing_general( org_id=organization_id, workload_type=WorkloadType.NON_API, ), + transport=transport, ) if not result: 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/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..e49aee7191 --- /dev/null +++ b/workers/pg_queue_consumer/__main__.py @@ -0,0 +1,57 @@ +"""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``. +""" + + +def _bootstrap_and_run() -> None: + # 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. + 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 + + main() + + +if __name__ == "__main__": + _bootstrap_and_run() 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..bc2a5564ed --- /dev/null +++ b/workers/pg_queue_consumer/supervisor.py @@ -0,0 +1,493 @@ +"""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, + } + + 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, + port=port, + 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", + ) + 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/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/pyproject.toml b/workers/pyproject.toml index 6344f8798d..b5c3c112aa 100644 --- a/workers/pyproject.toml +++ b/workers/pyproject.toml @@ -34,6 +34,10 @@ 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 + "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 @@ -59,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" ] @@ -154,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/queue_backend/__init__.py b/workers/queue_backend/__init__.py index 3fcb1bab62..d1c9154aa6 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 @@ -32,11 +41,17 @@ 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, barrier_decr_and_check, ) +from .routing import QueueBackend, select_backend __all__ = [ "Barrier", @@ -44,11 +59,16 @@ "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", "worker_task", ] @@ -65,6 +85,7 @@ class BarrierBackend(StrEnum): CHORD = "chord" REDIS = "redis" + PG = "pg" def get_barrier() -> Barrier: @@ -98,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..4695765de2 100644 --- a/workers/queue_backend/barrier.py +++ b/workers/queue_backend/barrier.py @@ -35,10 +35,13 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Protocol +import os +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 @@ -47,6 +50,134 @@ 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 _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(var) + if raw is None: + return default + try: + value = int(raw) + except ValueError as exc: + raise ValueError( + f"{var}={raw!r} is not an integer. Unset it to default to {human_default}." + ) from exc + if value <= 0: + raise ValueError( + 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. + + 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 + # 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 + + +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. @@ -66,9 +197,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, @@ -120,8 +257,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/dispatch.py b/workers/queue_backend/dispatch.py index cf8d2c274e..02b0b9636b 100644 --- a/workers/queue_backend/dispatch.py +++ b/workers/queue_backend/dispatch.py @@ -1,19 +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:`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`` + (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. + +.. 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 .fairness import DEFAULT_PRIORITY, FairnessKey from .handle import DispatchHandle +from .pg_queue import PgQueueClient, to_payload +from .routing import QueueBackend, resolve_backend + +logger = logging.getLogger(__name__) + +# 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( @@ -23,12 +78,31 @@ 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. + """Enqueue a task by name onto its selected transport. - ``fairness`` is attached as the ``x-fairness-key`` header (not in - kwargs). Pass ``None`` for non-workflow worker tasks. + ``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 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 return current_app.send_task( task_name, @@ -37,3 +111,60 @@ 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. + # 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 " + "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: + # 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 + # 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..29e5afdfa5 100644 --- a/workers/queue_backend/fairness.py +++ b/workers/queue_backend/fairness.py @@ -8,21 +8,33 @@ from __future__ import annotations from dataclasses import dataclass -from enum import StrEnum from typing import Final - -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 +# 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" @@ -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_barrier.py b/workers/queue_backend/pg_barrier.py new file mode 100644 index 0000000000..fab31c44de --- /dev/null +++ b/workers/queue_backend/pg_barrier.py @@ -0,0 +1,1395 @@ +"""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``, ``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 + ``.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. 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 +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 +import time +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final, NamedTuple + +import psycopg2 +import psycopg2.errors +import psycopg2.extensions + +from unstract.core.data_models import ( + DEFAULT_WORKFLOW_TRANSPORT, + WorkflowTransport, + is_pg_transport, +) + +from .barrier import ( + BarrierContext, + CallbackDescriptor, + barrier_ttl_seconds, + callback_recovery_identity, +) +from .decorator import worker_task +from .fairness import ( + DEFAULT_PRIORITY, + FAIRNESS_HEADER_NAME, + FairnessKey, + 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 + +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__) + + +# ``_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 +# 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 + + +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.""" + conn = _get_conn() + try: + with conn.cursor() as cur: + yield cur + conn.commit() + except Exception as exc: + _recover_after_error(conn, exc) + 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 + +# 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. +_BARRIER_DECREMENT_ATTEMPTS: Final = 2 # total attempts: 1 initial + 1 retry + + +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 _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 + # 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( + f"DELETE FROM {qualified('pg_barrier_state')} WHERE execution_id = %s", + (execution_id,), + ) + + +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. + + 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: + cur.execute( + 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", + (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. + + Called from the barrier-finalise + abort paths. + """ + with _cursor() as cur: + cur.execute( + f"DELETE FROM {qualified('pg_batch_dedup')} WHERE execution_id = %s", + (execution_id,), + ) + return cur.rowcount + + +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 + (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). ``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`, + 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 + 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; 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). + + **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, 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") + 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 + # 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 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 + + +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). + + 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. + """ + + 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, + *, + 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 + 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, + 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. + + ``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 dispatched by the decrement. + 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) + + is_pg = is_pg_transport(transport) + 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, + } + 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 + # 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 + # 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." + ) + + 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 + # concurrent enqueues; orphan reclaim is a separate (future) + # periodic sweep keyed on pg_barrier_expires_idx. + cur.execute( + f"INSERT INTO {qualified('pg_barrier_state')} " + "(execution_id, organization_id, remaining, results, " + " created_at, expires_at, last_progress_at) " + "VALUES (%s, %s, %s, '[]'::jsonb, now(), " + " 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, " + " 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 + # 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( + f"DELETE FROM {qualified('pg_batch_dedup')} WHERE execution_id = %s", + (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, + execution_id=execution_id, + callback_descriptor=callback_descriptor, + fairness=fairness, + fairness_headers=fairness_headers, + ) + + logger.info( + 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) + + 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 + + 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. + + 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=pg_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) + + +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. + """ + # ``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), " + " last_progress_at = now() " + " 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, + *, + execution_id: str, + callback_descriptor: CallbackDescriptor, +) -> dict[str, Any]: + """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 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 + # 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). + 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: + 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 + # 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 = row.remaining, row.results + 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. + # + # 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_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_id})" + ) + return { + "status": "complete", + "callback_task_id": callback_id, + "aggregated_count": len(all_results), + } + + +@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, + exc: Any = None, + 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. + + 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). + + ``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 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: + cur.execute( + "WITH claimed AS (" + f" DELETE FROM {qualified('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} + + 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"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, 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, 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." + ) + 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 _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 +# 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]: + """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() + if result.get(SKIPPED_TERMINAL_EXECUTION_KEY): + # 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." + ) + return result + _barrier_pg_decrement( + result, + execution_id=execution_id, + callback_descriptor=barrier_context["callback_descriptor"], + ) + except Exception: + 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/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/queue_backend/pg_queue/__init__.py b/workers/queue_backend/pg_queue/__init__.py new file mode 100644 index 0000000000..28139cefc2 --- /dev/null +++ b/workers/queue_backend/pg_queue/__init__.py @@ -0,0 +1,62 @@ +"""PG Queue transport substrate — scaffold. + +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 +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. + +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, +so they're tracked on the ticket / PR rather than baked in here. +""" + +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, + recover_expired_barriers, +) +from .task_payload import TaskPayload, to_payload + +__all__ = [ + "LeaderLease", + "LeaderLeaseLike", + "LivenessServer", + "PgQueueClient", + "PgReaper", + "QueueMessage", + "ReaperLivenessServer", + "TaskPayload", + "TickOutcome", + "create_pg_connection", + "default_worker_id", + "lease_seconds_from_env", + "reaper_interval_from_env", + "recover_expired_barriers", + "to_payload", +] diff --git a/workers/queue_backend/pg_queue/client.py b/workers/queue_backend/pg_queue/client.py new file mode 100644 index 0000000000..3bc1c5c068 --- /dev/null +++ b/workers/queue_backend/pg_queue/client.py @@ -0,0 +1,455 @@ +"""Thin client over the bespoke PG queue (extension-free, ``SKIP LOCKED``). + +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 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** +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 +import time +from collections.abc import Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final, Self + +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 + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + +logger = logging.getLogger(__name__) + + +# ``_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 +# 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 (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 +# (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. +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 {msg} + WHERE queue_name = %s + AND vt <= now() + ORDER BY priority DESC, msg_id + FOR UPDATE SKIP LOCKED + LIMIT %s +), claimed AS ( + UPDATE {msg} 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 +""" + + +@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. ``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 + + +# 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 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)" + ) + + +# 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``. + + 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). + # 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: + 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, + 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. + + 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", + # "" 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 "", + priority, + ), + ) + 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: + # 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 + ] + + 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. + + ``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 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", + (msg_id,), + ) + deleted = cur.rowcount + if deleted == 0: + 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 + + 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..4933b420cc --- /dev/null +++ b/workers/queue_backend/pg_queue/connection.py @@ -0,0 +1,171 @@ +"""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 +import time +from collections.abc import Callable +from typing import TYPE_CHECKING + +import psycopg2 + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + +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 +# 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. + + 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. + + 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") + + 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/consumer.py b/workers/queue_backend/pg_queue/consumer.py new file mode 100644 index 0000000000..55b23ee954 --- /dev/null +++ b/workers/queue_backend/pg_queue/consumer.py @@ -0,0 +1,936 @@ +"""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 json +import logging +import os +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 +from .result_backend import PgResultBackend +from .task_payload import to_payload + +if TYPE_CHECKING: + from celery import Celery + from shared.api import InternalAPIClient + + from .client import QueueMessage + +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 +# 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 +# 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 +# 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 + + +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 _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 + + +# 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 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); 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 ``""`` + 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 {} + ) + pos_execution_id, pos_org = _positional_identity(payload) + 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, + str(organization_id or ""), + ) + + +class PgQueueConsumer: + """Polls one PG queue, runs each claimed task in-process, acks on success.""" + + def __init__( + self, + queue_names: list[str], + *, + 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. + for name, value in ( + ("batch_size", batch_size), + ("vt_seconds", vt_seconds), + ("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}") + 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 ({poll_interval})" + ) + 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 + # 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 + # (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 + # 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 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() + 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 + 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") + # 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. + 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._fail_dispatch(payload, error="malformed message: missing task_name") + 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: + self._drop_poison_message(message, payload, task_name) + 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._fail_dispatch(payload, error=f"unknown task {task_name}") + 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 + 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 + # 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 + # 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: dispatch %r (msg_id=%s) failed — surfaced " + "via reply/on_error + acked", + task_name, + message.msg_id, + ) + 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", + task_name, + message.msg_id, + message.read_ct, + ) + 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, + ) + 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( + "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 _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 _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 _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). + + 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.")) + + 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. + + 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 (queues=%r, batch=%s, vt=%ss) — " + "%d application task(s) registered: %s", + self.queue_names, + self.batch_size, + self.vt_seconds, + len(app_tasks), + ", ".join(app_tasks) or "(none)", + ) + 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 (queues=%r)", self.queue_names) + + 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 _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 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 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=port, + stale_after=consumer_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(_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"``), 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", + ) + + +if __name__ == "__main__": + main() 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..892866a18b --- /dev/null +++ b/workers/queue_backend/pg_queue/executor_rpc.py @@ -0,0 +1,114 @@ +"""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 +:func:`get_executor_dispatcher` factory. + +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 unstract.sdk1.execution.dispatcher import ExecutionDispatcher +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", +] + + +def resolve_executor_transport(context: ExecutionContext) -> bool: + """True → route this executor dispatch over PG; False → Celery (default). + + The single ``pg_queue_enabled`` Flipt flag (fail-closed). + """ + return resolve_pg_transport(context) + + +class PgClientQueueTransport(QueueTransport): + """:class:`QueueTransport` over psycopg2 (the workers half). + + Inherits the Protocol so a type-checker verifies this implementation against the + seam independently of the ``PgExecutionDispatcher(...)`` construction site. + """ + + 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: + # 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, + on_success=on_success, + on_error=on_error, + task_id=task_id, + ) + with PgQueueClient() as client: + client.send(queue, payload, org_id=org_id) + + def wait_for_result(self, reply_key: str, timeout: float) -> ExecResultRow | None: + """Poll ``pg_task_result`` until the row appears or *timeout* elapses. + + ``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: + 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") + ) + + +def get_executor_dispatcher( + celery_app: object | None = None, +) -> RoutingExecutionDispatcher: + """Factory: the gate-routed executor dispatcher (PG when enabled, else Celery).""" + return RoutingExecutionDispatcher( + celery=ExecutionDispatcher(celery_app=celery_app), + pg=PgExecutionDispatcher(PgClientQueueTransport()), + resolve=resolve_executor_transport, + ) 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..403e84e388 --- /dev/null +++ b/workers/queue_backend/pg_queue/leader_election.py @@ -0,0 +1,269 @@ +"""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 +from .schema import qualified + +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 + + @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 + # 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( + f"UPDATE {qualified('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( + f"UPDATE {qualified('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( + f"UPDATE {qualified('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/queue_backend/pg_queue/liveness.py b/workers/queue_backend/pg_queue/liveness.py new file mode 100644 index 0000000000..07f41f6ceb --- /dev/null +++ b/workers/queue_backend/pg_queue/liveness.py @@ -0,0 +1,221 @@ +"""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 contextlib +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). + + ``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, + *, + freshness_fn: Callable[[], float], + stale_after: float, + port: int, + 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: + # 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._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 + # 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 + 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 + 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. + 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 + # 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() + # 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 + + 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/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/pg_scheduler.py b/workers/queue_backend/pg_queue/pg_scheduler.py new file mode 100644 index 0000000000..6ae64a5a93 --- /dev/null +++ b/workers/queue_backend/pg_queue/pg_scheduler.py @@ -0,0 +1,232 @@ +"""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 .schema import qualified +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( + f"UPDATE {qualified('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( + f""" + SELECT pipeline_id, organization_id, workflow_id, pipeline_name, + cron_string, next_run_at + FROM {qualified('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( + f"UPDATE {qualified('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 helper). + with conn.cursor() as cur: + cur.execute( + insert_message_sql(), + ( + SCHEDULER_QUEUE_NAME, + json.dumps(payload), + schedule.organization_id or "", + DEFAULT_PRIORITY, + ), + ) + cur.execute( + f"UPDATE {qualified('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 new file mode 100644 index 0000000000..9e848b7c3a --- /dev/null +++ b/workers/queue_backend/pg_queue/reaper.py @@ -0,0 +1,1367 @@ +"""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). It ships the process *harness* (lease-maintenance loop + +graceful shutdown) plus the **barrier-orphan recovery** job. + +**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). 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. + +**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 +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 +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 collections.abc import Callable +from typing import TYPE_CHECKING, Final, Literal, NamedTuple, Protocol, TypeVar + +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 +from .metrics import ReaperMetrics +from .pg_scheduler import dispatch_due_schedules +from .recovery import mark_execution_error +from .schema import qualified + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + from shared.api import InternalAPIClient + +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 +# 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 + +# 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) + +# 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. + + 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 _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( + f"DELETE FROM {qualified('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( + f"DELETE FROM {qualified('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: + """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) + + +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 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')} " + f"WHERE execution_id = %s AND {_STRANDED_PREDICATE}", + (execution_id, stuck_timeout_seconds), + ) + 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" + ) + 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, + remaining, + ) + + +def _recover_one_barrier( + conn: PgConnection, + api_client: InternalAPIClient, + 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. + + 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_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( + "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( + f"DELETE FROM {qualified('pg_barrier_state')} WHERE execution_id = %s " + f"AND {_STRANDED_PREDICATE}", + (execution_id, stuck_timeout_seconds), + ) + deleted = cur.rowcount > 0 + if deleted: + cur.execute( + f"DELETE FROM {qualified('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, + stuck_timeout_seconds: int | None = None, + metrics: ReaperMetrics | None = None, +) -> list[str]: + """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 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 + 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. + """ + 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 {_STRANDED_PREDICATE}", + (stuck_timeout_seconds,), + ) + rows = cur.fetchall() + conn.commit() + except Exception: + with contextlib.suppress(Exception): + conn.rollback() + raise + + 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, + stuck_timeout_seconds, + ): + 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 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 = ( + f"recovered={len(recovered)}, failed={failed}, skipped={skipped} " + f"of {len(rows)} expired barrier(s)" + ) + 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 + + +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, + 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). + + 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 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 " + "(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 + + +# 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.""" + + def __init__( + self, + lease: LeaderLeaseLike, + *, + 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 + 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" + ) + # 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 + # (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 + # 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: + """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). + 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 _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 + # `.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.""" + # 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() + except Exception: + # 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( + "Reaper: lost leadership (lease taken over) — stepping down " + "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") + if not self._is_leader: + return TickOutcome(was_leader=False, reclaimed=0) + try: + reclaimed = len( + recover_expired_barriers( + self._get_sweep_conn(), + self._get_api_client(), + self._stuck_timeout_seconds, + metrics=self._metrics, + ) + ) + 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 + # 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: + """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), + ) + # 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, + metrics=self._metrics, + ), + ) + if results or dedup or claims: + logger.info( + "Reaper: retention sweep deleted %s pg_task_result + " + "%s pg_batch_dedup + %s pg_orchestration_claim row(s)", + results, + dedup, + claims, + ) + + 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 + 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", + table, + streak, + ) + self._discard_owned_sweep_conn() + return 0 + 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 + 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. + # 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: + # 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" + ) + + +# 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}, + metrics_fn=reaper.metrics.render, + 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()) + 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__": + main() 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/queue_backend/pg_queue/result_backend.py b/workers/queue_backend/pg_queue/result_backend.py new file mode 100644 index 0000000000..3185999978 --- /dev/null +++ b/workers/queue_backend/pg_queue/result_backend.py @@ -0,0 +1,272 @@ +"""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). 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 + +import contextlib +import json +import logging +import time +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Any, Final, Self + +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 + +if TYPE_CHECKING: + from psycopg2.extensions import connection as PgConnection + +logger = logging.getLogger(__name__) + +# ``_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 +# 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. 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. +_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 + +# 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``.""" + + 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, _CONN_DEAD_ERRORS) + 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_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, + *, + 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. 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 "" + 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),)) + 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. 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. + """ + 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).""" + 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/queue_backend/pg_queue/schema.py b/workers/queue_backend/pg_queue/schema.py new file mode 100644 index 0000000000..50364cec7c --- /dev/null +++ b/workers/queue_backend/pg_queue/schema.py @@ -0,0 +1,88 @@ +"""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_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). + +**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_orchestration_claim", + "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/queue_backend/pg_queue/task_payload.py b/workers/queue_backend/pg_queue/task_payload.py new file mode 100644 index 0000000000..cefcf70e48 --- /dev/null +++ b/workers/queue_backend/pg_queue/task_payload.py @@ -0,0 +1,77 @@ +"""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 + +# 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 ContinuationSpec, TaskPayload + +if TYPE_CHECKING: + from ..fairness import FairnessKey + +__all__ = ["TaskPayload", "to_payload"] + + +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, + 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 [], + kwargs=dict(kwargs) if kwargs is not None else {}, + queue=queue, + fairness=fairness.to_dict() if fairness is not None else None, + ) + # 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/queue_backend/redis_barrier.py b/workers/queue_backend/redis_barrier.py index 5f79db9cd8..2c1e2fb3ee 100644 --- a/workers/queue_backend/redis_barrier.py +++ b/workers/queue_backend/redis_barrier.py @@ -64,12 +64,18 @@ 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 unstract.core.data_models import DEFAULT_WORKFLOW_TRANSPORT +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 +117,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)``. @@ -338,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. @@ -362,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") @@ -511,27 +490,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/queue_backend/routing.py b/workers/queue_backend/routing.py new file mode 100644 index 0000000000..228a296dfe --- /dev/null +++ b/workers/queue_backend/routing.py @@ -0,0 +1,155 @@ +"""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** 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). ``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 +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 + + +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/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 # ============================================================================= diff --git a/workers/run-worker.sh b/workers/run-worker.sh index 6597907a1a..0ec399deec 100755 --- a/workers/run-worker.sh +++ b/workers/run-worker.sh @@ -24,6 +24,69 @@ 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" +# 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" +# 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). +# 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" +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" + ["$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" + # 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 + ["$PG_QUEUE_REAPER_TYPE"]=1 + ["$PG_ROLE_ORCH_API"]=1 + ["$PG_ROLE_ORCH_GENERAL"]=1 + ["$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 +# 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 declare -A WORKERS=( @@ -44,7 +107,30 @@ 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" + # 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_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" + ["$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" + # '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 @@ -61,6 +147,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 +163,26 @@ declare -A WORKER_HEALTH_PORTS=( ["scheduler"]="8087" ["${EXECUTOR_WORKER_TYPE}"]="8088" ["${IDE_CALLBACK_WORKER_TYPE}"]="8089" + # 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" + # 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 +# 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 + ["$PG_QUEUE_REAPER_TYPE"]=1 ) # Function to display usage @@ -93,9 +202,26 @@ 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) - all Run all workers (in separate processes, includes auto-discovered pluggable workers) + 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) + 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) Note: Pluggable workers in pluggable_worker/ directory are automatically discovered and can be run by name. +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). +Note: reaper overrides: WORKER_PG_ORCHESTRATOR_LEASE_SECONDS (lease window, default 10), + 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) @@ -110,7 +236,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 @@ -124,6 +252,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 @@ -147,6 +278,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 @@ -248,9 +385,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" @@ -300,8 +439,34 @@ 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 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 [[ -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 + # 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 @@ -318,7 +483,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 @@ -345,10 +512,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 } @@ -357,8 +529,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") @@ -371,13 +550,23 @@ 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 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") + 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 @@ -475,6 +664,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 @@ -503,6 +698,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" @@ -647,28 +846,90 @@ 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. + 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" + 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; 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 + # bootstrap). Override the celery command with the plain `python -m` entry. + # 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 + 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 "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[*]}" # 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]:-}" || -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. PG_QUEUE_MEMBERS + # is the single source of truth for "which workers are PG-queue members". 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[@]}" @@ -730,6 +991,57 @@ run_all_workers() { fi } +# 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. +# 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 + + # 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 "${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 + # 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 (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 + print_status $GREEN "PG-queue set started in background" + show_status +} + # Parse command line arguments DETACH=false LOG_LEVEL="" @@ -743,6 +1055,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 @@ -808,12 +1124,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 @@ -844,7 +1164,26 @@ 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 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" && "${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" @@ -858,11 +1197,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 @@ -871,23 +1215,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 @@ -895,14 +1241,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" -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/sample.env b/workers/sample.env index 6b22187f33..42f9b70003 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 +# (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 +# (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. +# +# ⚠️ 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). +# +# 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 @@ -458,3 +486,19 @@ FLIPT_SERVICE_AVAILABLE=False EVALUATION_SERVER_IP=unstract-flipt EVALUATION_SERVER_PORT=9005 PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python + +# 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 + +# 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/scheduler/tasks.py b/workers/scheduler/tasks.py index b0bd0ec9f3..3c98fda9b9 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 @@ -23,7 +23,13 @@ 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, + is_pg_transport, + normalize_transport, +) # Import the exact backend logic to ensure consistency @@ -138,6 +144,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}" ) @@ -164,8 +181,13 @@ def _execute_scheduled_workflow( kwargs={ "use_file_history": context.use_file_history, "pipeline_id": context.pipeline_id, + "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/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/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/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/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/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/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/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/orchestration_utils.py b/workers/shared/workflow/execution/orchestration_utils.py index 5b7167774c..80bfa0bc23 100644 --- a/workers/shared/workflow/execution/orchestration_utils.py +++ b/workers/shared/workflow/execution/orchestration_utils.py @@ -10,6 +10,13 @@ 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, + ExecutionStatus, + is_pg_transport, +) from ...enums import FileDestinationType, PipelineType from ...enums.worker_enums import QueueName @@ -17,21 +24,89 @@ 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.""" + @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], @@ -41,6 +116,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 +147,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/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/conftest.py b/workers/tests/conftest.py index 084a8ef88c..d30d784cf5 100644 --- a/workers/tests/conftest.py +++ b/workers/tests/conftest.py @@ -6,9 +6,259 @@ time if INTERNAL_API_BASE_URL is not set. """ +import contextlib +import importlib +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) + +# 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) --- +# +# 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_") + + +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. + + Fails instead of skipping when ``REQUIRE_PG_TESTS`` is set (integration lane). + """ + try: + conn = integration_pg_conn() + except psycopg2.OperationalError as 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() + _skip_or_fail_no_pg("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) + + +# --- 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 e61bc4a182..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) @@ -392,6 +401,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 +410,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_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_chord_callback_boundary.py b/workers/tests/test_chord_callback_boundary.py index 524708a3e5..055ff69cc4 100644 --- a/workers/tests/test_chord_callback_boundary.py +++ b/workers/tests/test_chord_callback_boundary.py @@ -767,5 +767,59 @@ 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 + + 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, + "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 + 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 + + 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): + 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 + assert stages_kwargs.get("is_pg") is True # UN-3662 guard ON on PG + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/workers/tests/test_dispatch_pg.py b/workers/tests/test_dispatch_pg.py new file mode 100644 index 0000000000..c97a2664fa --- /dev/null +++ b/workers/tests/test_dispatch_pg.py @@ -0,0 +1,242 @@ +"""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 pytest +from queue_backend import dispatch +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. +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)) + + 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. + + +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() + + +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).""" + + @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_dispatch_sites_characterisation.py b/workers/tests/test_dispatch_sites_characterisation.py index 7900503b0a..9bf4daad3b 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,51 @@ 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_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.""" 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..e2bec43a62 --- /dev/null +++ b/workers/tests/test_executor_rpc.py @@ -0,0 +1,383 @@ +"""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 ( + 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, +) + +_WMOD = "queue_backend.pg_queue.executor_rpc" +_SMOD = "unstract.workflow_execution.executor_rpc" + + +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} + + +def _completed(result: dict) -> ExecResultRow: + return ExecResultRow(status="completed", result=result, error="") + + +class _FakeTransport: + """Records ``enqueue`` calls and returns a configured result for the poll. + + Lets the shared dispatcher's logic be exercised without any DB. + """ + + 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): + 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): + 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): + 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): + 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 = 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 = 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 = 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): + 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") + 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") + 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 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()) + + @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 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 + + +# --- Shared gate: resolve_pg_transport (single Flipt flag, fail-closed) --- + + +class TestResolvePgTransport: + 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()) 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")) 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()) 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()) 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)) is True + assert flag.call_args.kwargs["entity_id"] == "run-1" + assert "organization_id" not in flag.call_args.kwargs["context"] + + +# --- Shared routing: RoutingExecutionDispatcher (zero-regression) --- + + +class TestRoutingDispatch: + @staticmethod + 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): + d, celery, pg = self._build(route_to_pg=False) + ctx = _ctx() + hdrs = {"x-fairness-key": {"org_id": "o"}} + d.dispatch(ctx, timeout=9, headers=hdrs) + celery.dispatch.assert_called_once_with(ctx, timeout=9, headers=hdrs) + pg.dispatch.assert_not_called() + + def test_gate_on_passes_timeout_to_pg_and_drops_headers(self): + d, celery, pg = self._build(route_to_pg=True) + ctx = _ctx() + 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): + 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): + 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() + assert "headers" not in pg.dispatch_with_callback.call_args.kwargs + celery.dispatch_async.assert_not_called() + celery.dispatch_with_callback.assert_not_called() + + +# --- Workers adapter: PgClientQueueTransport + env gate + factory wiring --- + + +class TestWorkersAdapter: + @staticmethod + def _ctx(): + c = MagicMock() + c.executor_name = "legacy" + c.run_id = "r" + c.to_dict.return_value = {"run_id": "r"} + return c + + @staticmethod + def _client(): + client = MagicMock() + client.__enter__.return_value = client # `with PgQueueClient() as c` + return client + + def test_enqueue_sends_queue_payload_and_org(self): + client = self._client() + 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["args"] == [{"run_id": "r"}] + assert payload_arg["reply_key"] == "rk1" + + def test_enqueue_carries_continuations(self): + client = self._client() + 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", + ) + 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_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 + 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_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' Flipt resolver. + assert isinstance(d._pg._transport, PgClientQueueTransport) + assert d._resolve is resolve_executor_transport + + +# --- 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=(), + 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" + with pytest.raises(AttributeError): + handle.result = 1 # type: ignore[attr-defined] 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"]) 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_orchestration_idempotency.py b/workers/tests/test_orchestration_idempotency.py new file mode 100644 index 0000000000..4768ad583a --- /dev/null +++ b/workers/tests/test_orchestration_idempotency.py @@ -0,0 +1,373 @@ +"""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 MagicMock, 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", "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", "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", "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- + # 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", "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 + # 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", "org1", "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, _org: 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, _org): + 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, _org: 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 + + 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 --- + + +@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", "org-1") is True + + def test_duplicate_claim_loses(self, claim_db): + 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", "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", "org-1") is True + assert _claim_count(claim_db, "exec-keep") == 1 + 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", "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", "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 + 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, "org-1") + 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 new file mode 100644 index 0000000000..b49713a4e7 --- /dev/null +++ b/workers/tests/test_pg_barrier.py @@ -0,0 +1,1618 @@ +"""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 psycopg2.extensions +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_decrement, + _fire_barrier_callback, + barrier_pg_abort, + 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 + + +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", + "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 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. + 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, + ) + + +# --- 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. + ``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, 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 + + 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 + if self._commit_error is not None: + raise self._commit_error + + def rollback(self): + self.rollbacks += 1 + + 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(): + """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" + ) + + +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 + + +@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). + + 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_") + except psycopg2.OperationalError as exc: + pytest.skip(f"Postgres not reachable: {exc}") + try: + assert conn.autocommit is False + finally: + 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", "org-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", "org-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", "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") + + +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 --- + + +@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() + + +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 + + +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)] + 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_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, + # 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( + [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, 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( + [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_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() + 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, organization_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_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 + ): + # 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: + 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 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) + 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, organization_id, remaining, results, " + " created_at, expires_at) " + "VALUES ('bad', '', 1, '[]'::jsonb, now(), now())" # expires==created + ) + + +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 _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, + "callback_descriptor": _CALLBACK, + } + + def boom(): + raise RuntimeError("batch failed") + + with pytest.raises(RuntimeError, match="batch failed"): + run_batch_with_barrier(ctx, boom) + 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"} + 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_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. + 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_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]) + # 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, 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. 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, + "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 + + +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_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"]) 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_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) 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 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_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_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, + ) diff --git a/workers/tests/test_pg_queue_client.py b/workers/tests/test_pg_queue_client.py new file mode 100644 index 0000000000..988f4c2166 --- /dev/null +++ b/workers/tests/test_pg_queue_client.py @@ -0,0 +1,659 @@ +"""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 logging +import os +import time +import uuid +from unittest.mock import MagicMock + +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.client import _SEND_RETRY_BACKOFF_SECONDS +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 --- + + +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 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 + 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_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 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) + 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): + conn, cur = _mock_conn(rowcount=1) + assert PgQueueClient(conn=conn).delete(7) is True + sql, params = cur.execute.call_args.args + assert f"DELETE FROM {qualified('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_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"): + 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 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 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).""" + + 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 (pg_conn fixture from conftest.py) --- + + +@pytest.fixture +def queue_name(pg_conn): + # 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. + 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_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}) + 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}) + # 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)} + 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/tests/test_pg_queue_consumer.py b/workers/tests/test_pg_queue_consumer.py new file mode 100644 index 0000000000..38faa94640 --- /dev/null +++ b/workers/tests/test_pg_queue_consumer.py @@ -0,0 +1,980 @@ +"""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, patch + +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") + + +@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() + + +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() + + +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", "") + + 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 + (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_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", + 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=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=client, 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 + + 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. 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 + + # 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) + + 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, 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", set_as_current=False) + 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, 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", 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 consumer._registered_task_count() == 1 + + +# --- 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. + + +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() + + +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 + + +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 + + +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"]) diff --git a/workers/tests/test_pg_reaper.py b/workers/tests/test_pg_reaper.py new file mode 100644 index 0000000000..f8459ee8a5 --- /dev/null +++ b/workers/tests/test_pg_reaper.py @@ -0,0 +1,1371 @@ +"""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 logging +import os +import threading +import time +from types import SimpleNamespace +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, + dedup_retention_from_env, + reaper_interval_from_env, + 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 + + +# 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 + + +# 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) + claims = MagicMock(return_value=0) + monkeypatch.setattr(reaper_mod, "sweep_expired_results", results) + monkeypatch.setattr(reaper_mod, "sweep_orphan_dedup", dedup) + monkeypatch.setattr(reaper_mod, "sweep_orphan_claims", claims) + return SimpleNamespace(results=results, dedup=dedup, claims=claims) + + +# --- 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 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``). + """ + + 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()) + + 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) --- + + +class TestLeadershipGating: + def _reaper(self, lease): + # 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, "recover_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, "recover_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, "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 + + 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, "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 + 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, "recover_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, "recover_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() + + +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 + + +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 ( + 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 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() + + @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) + # 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, + metrics=reaper.metrics, + ) + + 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 + 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)) + # 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) --- + + +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, + 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 = [] + + 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, + 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 SimpleNamespace(success=self._update_success) + + +class TestRecoverConnection: + def test_select_sql_contract(self): + cur = MagicMock() + cur.fetchall.return_value = [] # nothing expired + conn = MagicMock() + conn.cursor.return_value.__enter__.return_value = cur + api = MagicMock() + assert recover_expired_barriers(conn, api) == [] + sql = cur.execute.call_args[0][0] + 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 + conn.commit.assert_called_once() + api.update_workflow_execution_status.assert_not_called() + + 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): + recover_expired_barriers(conn, MagicMock()) + 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, + api_client=object(), # injected so tick() doesn't build a real client + ) + with patch.object( + reaper_mod, + "recover_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 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_") + + +@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") + 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, + 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 + else ("now()", "now() + interval '6 hours'") + ) + with conn.cursor() as cur: + cur.execute( + "INSERT INTO pg_barrier_state " + "(execution_id, organization_id, remaining, results, " + " 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() + + +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 + + +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) + 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 + # 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): + _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_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) + 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_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. + 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 recover_expired_barriers(barrier_conn, _FakeApiClient()) == [] + assert _ids(barrier_conn) == ["fresh-1"] + + 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 recovered the orphan + assert outcome.was_leader is True + assert outcome.reclaimed == 1 + 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, "recover_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) --- + + +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 + + +# --- 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"]) 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 diff --git a/workers/tests/test_pg_result_backend.py b/workers/tests/test_pg_result_backend.py new file mode 100644 index 0000000000..10a565fc5b --- /dev/null +++ b/workers/tests/test_pg_result_backend.py @@ -0,0 +1,312 @@ +"""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 +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, +) + +_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} + + +# --- 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" 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/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) + ) 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 86b73fe0a2..abc0ac1547 100644 --- a/workers/tests/test_queue_backend_seam.py +++ b/workers/tests/test_queue_backend_seam.py @@ -197,9 +197,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") @@ -284,17 +284,28 @@ 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. + # 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", "worker_task", } 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"] diff --git a/workers/tests/test_routing.py b/workers/tests/test_routing.py new file mode 100644 index 0000000000..7c3e6447e8 --- /dev/null +++ b/workers/tests/test_routing.py @@ -0,0 +1,229 @@ +"""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 MagicMock, 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() + dispatch_mod._pg_local.client = None # drop the per-thread PG client + + +# --- 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 + + +# --- 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") as mock_app, + patch("queue_backend.dispatch._get_pg_client") as mock_get, + ): + dispatch("t1", args=["a"], kwargs={"k": "v"}, queue="general") + mock_app.send_task.assert_called_once() + mock_get.assert_not_called() + + 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") 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 "routing task=" in r.getMessage() + ] + assert len(hits) == 1 + + 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__": + pytest.main([__file__, "-v"]) 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(), 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"]) diff --git a/workers/tests/test_terminal_execution_guard.py b/workers/tests/test_terminal_execution_guard.py new file mode 100644 index 0000000000..bc301979bc --- /dev/null +++ b/workers/tests/test_terminal_execution_guard.py @@ -0,0 +1,204 @@ +"""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 (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 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 +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, + _setup_execution_context, + _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) +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 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() + 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 + # 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. + 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", is_pg=True) + + 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(): + """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} + 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", is_pg=False) + + pre_create.assert_called_once() + process_files.assert_called_once() + assert result["successful_files"] == 1 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" diff --git a/workers/uv.lock b/workers/uv.lock index d4f3b24046..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,114 @@ 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]] +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]] @@ -873,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]] @@ -927,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]] @@ -978,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]] @@ -990,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]] @@ -1024,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]] @@ -1038,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]] @@ -1052,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" @@ -1061,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]] @@ -1073,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]] @@ -1146,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] @@ -1258,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]] @@ -1276,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]] @@ -1292,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] @@ -1314,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]] @@ -1327,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]] @@ -1340,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]] @@ -1353,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]] @@ -1373,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]] @@ -1386,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]] @@ -1401,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]] @@ -1417,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]] @@ -1452,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]] @@ -1464,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] @@ -1478,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]] @@ -1525,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]] @@ -1538,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]] @@ -1561,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]] @@ -1573,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]] @@ -1616,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]] @@ -1630,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]] @@ -1659,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]] @@ -1713,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]] @@ -1725,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]] @@ -1740,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] @@ -1765,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]] @@ -1795,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]] @@ -1843,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]] @@ -1944,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]] @@ -1956,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]] @@ -1971,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]] @@ -2054,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]] @@ -2068,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]] @@ -2086,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]] @@ -2105,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]] @@ -2119,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]] @@ -2158,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]] @@ -2171,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]] @@ -2185,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]] @@ -2198,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]] @@ -2211,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]] @@ -2228,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]] @@ -2241,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]] @@ -2254,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]] @@ -2267,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]] @@ -2283,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]] @@ -2297,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]] @@ -2310,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]] @@ -2324,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]] @@ -2336,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]] @@ -2349,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]] @@ -2361,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]] @@ -2436,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]] @@ -2467,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]] @@ -2482,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]] @@ -2494,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]] @@ -2608,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]] @@ -2668,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]] @@ -2745,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]] @@ -2769,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]] @@ -2788,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]] @@ -2801,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]] @@ -2815,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]] @@ -2828,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]] @@ -2845,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]] @@ -2862,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]] @@ -2877,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]] @@ -2889,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]] @@ -2903,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]] @@ -2916,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]] @@ -2928,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]] @@ -2961,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]] @@ -3008,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]] @@ -3030,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]] @@ -3044,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]] @@ -3056,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]] @@ -3141,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]] @@ -3189,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]] @@ -3210,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]] @@ -3306,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]] @@ -3418,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]] @@ -3451,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]] @@ -3463,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]] @@ -3537,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] @@ -3554,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] @@ -3595,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]] @@ -3645,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]] @@ -3666,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]] @@ -3729,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]] @@ -3742,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]] @@ -3756,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]] @@ -3768,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 }, +] + +[[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/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/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 }, ] [[package]] @@ -3780,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]] @@ -3801,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]] @@ -3823,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]] @@ -3871,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]] @@ -3941,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]] @@ -3964,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]] @@ -4067,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]] @@ -4080,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]] @@ -4092,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]] @@ -4106,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]] @@ -4119,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]] @@ -4212,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]] @@ -4226,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] @@ -4243,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]] @@ -4273,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]] @@ -4325,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] @@ -4354,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]] @@ -4376,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] @@ -4427,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]] @@ -4458,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]] @@ -4513,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]] @@ -4548,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]] @@ -4563,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]] @@ -4585,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]] @@ -4597,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]] @@ -4903,9 +4927,11 @@ source = { editable = "." } 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" }, @@ -4944,6 +4970,7 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "pytest-timeout" }, { name = "responses" }, ] @@ -4951,9 +4978,11 @@ 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" }, + { 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" }, @@ -4992,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" }, ] @@ -5003,6 +5033,7 @@ dependencies = [ { name = "unstract-core" }, { name = "unstract-filesystem" }, { name = "unstract-flags" }, + { name = "unstract-sdk1" }, { name = "unstract-tool-registry" }, { name = "unstract-tool-sandbox" }, ] @@ -5012,6 +5043,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" }, ] @@ -5020,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]] @@ -5074,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]] @@ -5144,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]] @@ -5158,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 }, ]